agent-loft 0.2.13 → 0.2.15

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/README.md CHANGED
@@ -49,6 +49,20 @@ agent-loft service install # 注册为开机自启服务(systemd / laun
49
49
 
50
50
  打开 `http://<机器 IP>:8800`:第一次需要输入日志里的设置码,然后设一个登录密码。
51
51
 
52
+ 设置 → 通用 里能看到服务端版本,有新版本时点「升级」即可(`npm install -g agent-loft@latest`,以系统服务运行时会自动重启);设置 → Agent 里能检查 Claude Code、Codex 装好、登录了没有,Codex 可以一键装到服务端上。
53
+
54
+ ### macOS 桌面端
55
+
56
+ 到 [Releases](https://github.com/qijieinnet/agent-loft/releases) 下载 `desktop-v*` 里对应芯片的 DMG(Apple 芯片选 arm64,Intel 选 x64),拖进「应用程序」。还没有 Apple 开发者签名,第一次打开如果提示无法验证,到 系统设置 → 隐私与安全性 里点「仍要打开」。
57
+
58
+ - 同时连多台服务器,左边一栏切换;每台各自登录,切走了也在后台连着,回复完成、需要授权时弹系统通知。
59
+ - 菜单栏图标:悬停看各服务器的 Claude / Codex 额度、待处理和运行中的会话。
60
+ - 支持前面挡着 Cloudflare Access 的服务器:验证页直接在窗口里完成,过期会提醒;也可以配 Service Token 免验证。
61
+ - 首次打开可以选「这台 Mac 也作为服务端」:一键在终端里安装 agent-loft(没有 Node.js 会先用 Homebrew 装)并开机自启,装好自动连上。
62
+ - 服务端有新版本时在服务器列表里点「升级服务端」;桌面端自己的更新在 设置 → 更新 里点「更新并重启」。
63
+
64
+ 桌面端只是个壳(代码在 `desktop/`),服务端照常用 npm 安装和升级。
65
+
52
66
  ### 从旧桥接器迁移
53
67
 
54
68
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -0,0 +1,169 @@
1
+ // Agent 环境体检:装没装、登没登录、怎么修。给 Web 设置页和桌面端的「Agent 环境」用。
2
+ //
3
+ // 修复办法只给命令(给人看、给人复制),这里不替人执行:安装要联网、登录要在浏览器里点,
4
+ // 都得在用户眼皮底下的终端里做。
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import { createRequire } from "node:module";
7
+ import path from "node:path";
8
+ import { agentEnvironment } from "../core/agent-env.js";
9
+ import { config } from "../core/config.js";
10
+ import { withProbe } from "./claude/probe.js";
11
+
12
+ const require = createRequire(import.meta.url);
13
+
14
+ export type AgentFix = {
15
+ action: "install" | "login";
16
+ title: string;
17
+ /** 每一条是一种做法,任选其一(比如 Homebrew 和 npm 两种装法) */
18
+ commands: string[];
19
+ note?: string;
20
+ };
21
+
22
+ export type AgentHealth = {
23
+ id: "claude" | "codex";
24
+ label: string;
25
+ enabled: boolean;
26
+ installed: boolean;
27
+ version?: string;
28
+ /** null:判断不了(探测出错、超时) */
29
+ loggedIn: boolean | null;
30
+ account?: string;
31
+ problem?: string;
32
+ fix?: AgentFix;
33
+ };
34
+
35
+ function run(cmd: string, args: string[], timeoutMs: number, env?: NodeJS.ProcessEnv): Promise<{ code: number; out: string }> {
36
+ return new Promise((resolve) => {
37
+ let out = "";
38
+ let child;
39
+ try {
40
+ child = spawn(cmd, args, { env: agentEnvironment(env ?? process.env), stdio: ["ignore", "pipe", "pipe"] });
41
+ } catch (err) {
42
+ resolve({ code: -1, out: err instanceof Error ? err.message : String(err) });
43
+ return;
44
+ }
45
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
46
+ child.stdout.on("data", (d) => (out += d));
47
+ child.stderr.on("data", (d) => (out += d));
48
+ child.on("error", (err) => {
49
+ clearTimeout(timer);
50
+ resolve({ code: -1, out: err.message });
51
+ });
52
+ child.on("close", (code) => {
53
+ clearTimeout(timer);
54
+ resolve({ code: code ?? -1, out: out.trim() });
55
+ });
56
+ });
57
+ }
58
+
59
+ function timeout<T>(ms: number, what: string): Promise<T> {
60
+ return new Promise((_, reject) => setTimeout(() => reject(new Error(`${what}超时`)), ms).unref());
61
+ }
62
+
63
+ function quote(p: string): string {
64
+ return /^[\w./-]+$/.test(p) ? p : `"${p.replace(/(["\\$`])/g, "\\$1")}"`;
65
+ }
66
+
67
+ /** 登录用哪个 claude:系统里装了 Claude Code 就用它,否则用 SDK 自带的那份(同一个程序) */
68
+ export function claudeBinary(): string {
69
+ if (spawnSync("sh", ["-c", "command -v claude"], { encoding: "utf8" }).stdout.trim()) return "claude";
70
+ try {
71
+ const pkg = `@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/package.json`;
72
+ return path.join(path.dirname(require.resolve(pkg)), "claude");
73
+ } catch {
74
+ return "claude";
75
+ }
76
+ }
77
+
78
+ async function claudeHealth(): Promise<AgentHealth> {
79
+ const base = { id: "claude" as const, label: "Claude Code", enabled: config().agents.claude.enabled, installed: true };
80
+ const loginFix: AgentFix = {
81
+ action: "login",
82
+ title: "登录 Claude",
83
+ commands: [quote(claudeBinary())],
84
+ note: "打开后输入 /login,按提示在浏览器里登录,完成后输入 /exit。用 API Key 的话,把 ANTHROPIC_API_KEY 写进服务端的环境变量也行。",
85
+ };
86
+ try {
87
+ const info = await Promise.race([withProbe((q) => q.accountInfo()), timeout<never>(25_000, "读取 Claude 账号")]);
88
+ const thirdParty = info.apiProvider && info.apiProvider !== "firstParty";
89
+ const loggedIn = !!(info.email || info.apiKeySource || thirdParty || (info.tokenSource && info.tokenSource !== "none"));
90
+ const account = info.email
91
+ ? `${info.email}${info.subscriptionType ? `(${info.subscriptionType})` : ""}`
92
+ : info.apiKeySource
93
+ ? `API Key(${info.apiKeySource})`
94
+ : thirdParty
95
+ ? String(info.apiProvider)
96
+ : undefined;
97
+ return { ...base, loggedIn, ...(account ? { account } : {}), ...(loggedIn ? {} : { fix: loginFix }) };
98
+ } catch (err) {
99
+ return { ...base, loggedIn: null, problem: err instanceof Error ? err.message : String(err), fix: loginFix };
100
+ }
101
+ }
102
+
103
+ async function codexHealth(): Promise<AgentHealth> {
104
+ const cfg = config().agents.codex;
105
+ const base = { id: "codex" as const, label: "Codex", enabled: cfg.enabled };
106
+ const env = { ...process.env, ...(cfg.home ? { CODEX_HOME: cfg.home } : {}) };
107
+ const ver = await run(cfg.path, ["--version"], 10_000, env);
108
+ if (ver.code !== 0) {
109
+ return {
110
+ ...base,
111
+ installed: false,
112
+ loggedIn: null,
113
+ problem: `找不到 ${cfg.path}`,
114
+ fix: {
115
+ action: "install",
116
+ title: "安装 Codex",
117
+ commands: ["brew install --cask codex", "npm install -g @openai/codex"],
118
+ note: "有 Homebrew 用第一条,否则用第二条(需要 Node.js)。agent-loft 以系统服务运行时 PATH 很短,装好后在下面把「codex 可执行文件」填成绝对路径(终端里 command -v codex 可以查到)。",
119
+ },
120
+ };
121
+ }
122
+ const version = ver.out.split("\n")[0]?.trim();
123
+ const st = await run(cfg.path, ["login", "status"], 10_000, env);
124
+ const loggedIn = st.code === 0 && !/not logged in/i.test(st.out);
125
+ return {
126
+ ...base,
127
+ installed: true,
128
+ ...(version ? { version } : {}),
129
+ loggedIn,
130
+ ...(loggedIn ? { account: st.out.split("\n")[0]?.trim() } : {}),
131
+ ...(loggedIn ? {} : { fix: { action: "login" as const, title: "登录 Codex", commands: [`${quote(cfg.path)} login`], note: "会打开浏览器,用 ChatGPT 账号登录;也可以 codex login --with-api-key 用 API Key。" } }),
132
+ };
133
+ }
134
+
135
+ let cache: { at: number; value: AgentHealth[] } | null = null;
136
+
137
+ /**
138
+ * 不等体检、立刻给出的「这个 agent 能不能用」:用最近一次体检的结果(多旧都算),
139
+ * 结果超过 5 分钟就顺手在后台再查一次。还没查过返回 null(当作能用,不挡人)。
140
+ * 新对话里只列出能用的 agent,没装、没登录的不出现。
141
+ */
142
+ export function agentAvailability(id: AgentHealth["id"]): { available: boolean; reason?: string } | null {
143
+ if (!cache || Date.now() - cache.at > 5 * 60_000) void agentHealth().catch(() => {});
144
+ const h = cache?.value.find((a) => a.id === id);
145
+ if (!h) return null;
146
+ if (!h.installed) return { available: false, reason: "未安装" };
147
+ if (h.loggedIn === false) return { available: false, reason: "未登录" };
148
+ return { available: true };
149
+ }
150
+
151
+ /** 装了 / 登录了什么之后,下次体检不用缓存 */
152
+ export function invalidateHealth(): void {
153
+ cache = null;
154
+ }
155
+ let inflight: Promise<AgentHealth[]> | null = null;
156
+
157
+ /** 两个 agent 一起查,结果缓存一分钟;refresh 跳过缓存(点了「重新检查」) */
158
+ export function agentHealth(refresh = false): Promise<AgentHealth[]> {
159
+ if (!refresh && cache && Date.now() - cache.at < 60_000) return Promise.resolve(cache.value);
160
+ inflight ??= Promise.all([claudeHealth(), codexHealth()])
161
+ .then((value) => {
162
+ cache = { at: Date.now(), value };
163
+ return value;
164
+ })
165
+ .finally(() => {
166
+ inflight = null;
167
+ });
168
+ return inflight;
169
+ }
@@ -12,6 +12,8 @@ import { getConversation, updateConversation } from "../../core/store.js";
12
12
  import { resolveUploadFile, saveBuffer, sniff } from "../../core/uploads.js";
13
13
  import type { SavedAttachment } from "../../core/types.js";
14
14
  import { listAllowlist, setAllowlist } from "../../agents/claude/permissions.js";
15
+ import { agentAvailability, agentHealth, invalidateHealth } from "../../agents/health.js";
16
+ import { installCodex, upgrade, useInstalledCodex, versionInfo } from "../../core/upgrade.js";
15
17
  import { feishuBots } from "../feishu/index.js";
16
18
  import { beginRegistration, initRegistration, pollRegistration, qrSvg } from "../feishu/register.js";
17
19
  import { FeishuClient } from "../feishu/client.js";
@@ -315,6 +317,8 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
315
317
  capabilities: d?.capabilities ?? {},
316
318
  // 输入框模型列表里显示哪些,空 = 全部
317
319
  models: config().agents[id].models.filter((m) => typeof m === "string"),
320
+ // 没装 / 没登录:新对话里不出现(null = 还没查过,当作能用)
321
+ ...(agentAvailability(id) ?? {}),
318
322
  };
319
323
  }),
320
324
  bots: config().feishu.bots.map((b) => ({ id: b.id, name: b.name, agents: b.agents, enabled: b.enabled })),
@@ -539,6 +543,27 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
539
543
  return;
540
544
  }
541
545
  // 侧栏的账号额度:各 agent 自己缓存(60 秒),这里并发问一遍,单个失败 / 超时不影响别的
546
+ if (p === "/api/server/version" && method === "GET") {
547
+ sendJson(res, 200, await versionInfo(url.searchParams.get("refresh") === "1"));
548
+ return;
549
+ }
550
+ if (p === "/api/server/upgrade" && method === "POST") {
551
+ sendJson(res, 200, await upgrade().catch((err) => {
552
+ throw new HttpError(400, err instanceof Error ? err.message : String(err));
553
+ }));
554
+ return;
555
+ }
556
+ if (p === "/api/agents/codex/install" && method === "POST") {
557
+ const result = await installCodex();
558
+ if (result.ok) useInstalledCodex();
559
+ invalidateHealth();
560
+ sendJson(res, 200, result);
561
+ return;
562
+ }
563
+ if (p === "/api/agents/health" && method === "GET") {
564
+ sendJson(res, 200, { agents: await agentHealth(url.searchParams.get("refresh") === "1") });
565
+ return;
566
+ }
542
567
  if (p === "/api/quota" && method === "GET") {
543
568
  const results = await Promise.all(
544
569
  hub.enabledAgents().map(async (d) => {
@@ -180,31 +180,45 @@ function convName(key: string): string {
180
180
  return conv.title?.trim() || hub.label(conv.agent);
181
181
  }
182
182
 
183
- /** 只推 Web 自己的会话:飞书会话的动静飞书已经会提醒 */
184
- function notifiable(key: string): boolean {
185
- return getConversation(key)?.channel === "web" && hasSubscriptions() && !watching(key);
183
+ /** 只提醒 Web 自己的会话:飞书会话的动静飞书已经会提醒 */
184
+ function isWebConv(key: string): boolean {
185
+ return getConversation(key)?.channel === "web";
186
+ }
187
+
188
+ type Notice = { title: string; body: string; tag: string };
189
+
190
+ /**
191
+ * 一条要提醒的动静,走两条路:
192
+ * - 广播给所有连着的页面(t: "notice"):桌面端据此弹系统通知,要不要弹由它自己判断
193
+ * (它知道自己是不是正看着这条会话)。普通网页忽略。
194
+ * - Web Push:只在没有页面正看着这条会话时推。
195
+ */
196
+ function notify(key: string, notice: Notice): void {
197
+ if (!isWebConv(key)) return;
198
+ for (const client of clients) send(client, { t: "notice", key, ...notice });
199
+ if (hasSubscriptions() && !watching(key)) void pushToAll({ ...notice, url: `/#${encodeURIComponent(key)}` });
186
200
  }
187
201
 
188
202
  function pushEvent(env: EventEnvelope): void {
189
203
  const ev = env.ev;
190
204
  if (ev.type !== "result" && ev.type !== "error") return;
191
- if (!notifiable(env.key)) return;
205
+ if (!isWebConv(env.key)) return;
192
206
  const name = convName(env.key);
193
207
  const payload =
194
208
  ev.type === "result"
195
209
  ? { title: `${name} · 回复完成`, body: plainPreview(ev.text) || "(没有文字回复)" }
196
210
  : { title: `${name} · 出错了`, body: plainPreview(ev.message) };
197
- void pushToAll({ ...payload, tag: `reply:${env.key}`, url: `/#${encodeURIComponent(env.key)}` });
211
+ notify(env.key, { ...payload, tag: `reply:${env.key}` });
198
212
  }
199
213
 
200
214
  function pushInteraction(i: Interaction): void {
201
- if (!notifiable(i.convKey)) return;
215
+ if (!isWebConv(i.convKey)) return;
202
216
  const name = convName(i.convKey);
203
217
  const payload =
204
218
  i.spec.kind === "approval"
205
219
  ? { title: `${name} · 需要授权`, body: plainPreview(i.spec.title) }
206
220
  : { title: `${name} · 有问题要你回答`, body: plainPreview(i.spec.questions[0]?.question ?? i.spec.title) };
207
- void pushToAll({ ...payload, tag: `ix:${i.id}`, url: `/#${encodeURIComponent(i.convKey)}` });
221
+ notify(i.convKey, { ...payload, tag: `ix:${i.id}` });
208
222
  }
209
223
 
210
224
  const BY = "已在 Web 处理";
@@ -0,0 +1,200 @@
1
+ // 服务端自升级:查 npm 上的最新版本,npm install -g 升级,跑在系统服务下就自动重启。
2
+ //
3
+ // 只支持 npm 全局安装(npm install -g agent-loft):从源码跑的(git clone)该用 git pull,
4
+ // 别的装法不知道怎么升级,都不碰。
5
+ import { spawn, spawnSync } from "node:child_process";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+ import { config, saveConfig } from "./config.js";
10
+
11
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
12
+
13
+ function readPackage(): { name: string; version: string } {
14
+ return JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
15
+ }
16
+
17
+ /** 当前进程跑的版本(升级后磁盘上的文件变了,进程里的还是旧的,直到重启) */
18
+ export const RUNNING_VERSION = readPackage().version;
19
+ const NAME = readPackage().name;
20
+
21
+ /** npm 和 node 放在一起(官方安装包、Homebrew、fnm、nvm 都是),用同一套,装到同一个地方 */
22
+ function npmBin(): string {
23
+ const beside = path.join(path.dirname(process.execPath), process.platform === "win32" ? "npm.cmd" : "npm");
24
+ return fs.existsSync(beside) ? beside : "npm";
25
+ }
26
+
27
+ function npmEnv(): NodeJS.ProcessEnv {
28
+ // 系统服务下 PATH 很短:npm 是个 node 脚本,得让它找得到 node
29
+ return { ...process.env, PATH: `${path.dirname(process.execPath)}${path.delimiter}${process.env.PATH ?? ""}` };
30
+ }
31
+
32
+ function run(cmd: string, args: string[], timeoutMs: number): Promise<{ code: number; out: string }> {
33
+ return new Promise((resolve) => {
34
+ let out = "";
35
+ const child = spawn(cmd, args, { env: npmEnv(), stdio: ["ignore", "pipe", "pipe"] });
36
+ const timer = setTimeout(() => child.kill("SIGKILL"), timeoutMs);
37
+ child.stdout.on("data", (d) => (out += d));
38
+ child.stderr.on("data", (d) => (out += d));
39
+ child.on("error", (err) => {
40
+ clearTimeout(timer);
41
+ resolve({ code: -1, out: err.message });
42
+ });
43
+ child.on("close", (code) => {
44
+ clearTimeout(timer);
45
+ resolve({ code: code ?? -1, out: out.trim() });
46
+ });
47
+ });
48
+ }
49
+
50
+ /** 由谁托管:launchd 会把标签放进 XPC_SERVICE_NAME,systemd 会设 INVOCATION_ID */
51
+ export function serviceManager(): "launchd" | "systemd" | null {
52
+ const xpc = process.env.XPC_SERVICE_NAME ?? "";
53
+ if (/^com\.(agent-loft|im-agent)/.test(xpc)) return "launchd";
54
+ if (process.env.INVOCATION_ID) return "systemd";
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * npm 全局安装的目录结构:<prefix>/lib/node_modules/agent-loft,命令链接在 <prefix>/bin/agent-loft
60
+ * (Windows 上是 <prefix>/node_modules/agent-loft 和 <prefix>/agent-loft.cmd)。
61
+ * 按结构认,不去问 npm root -g:npm 会把输出里像密钥的串(比如路径里的 UUID)打成 ***。
62
+ */
63
+ function globalPrefix(): string | null {
64
+ const real = (p: string) => {
65
+ try {
66
+ return fs.realpathSync(p);
67
+ } catch {
68
+ return null;
69
+ }
70
+ };
71
+ const self = real(root);
72
+ if (!self || path.basename(path.dirname(self)) !== "node_modules") return null;
73
+ if (process.platform === "win32") {
74
+ const prefix = path.dirname(path.dirname(self));
75
+ return fs.existsSync(path.join(prefix, `${NAME}.cmd`)) ? prefix : null;
76
+ }
77
+ if (path.basename(path.dirname(path.dirname(self))) !== "lib") return null;
78
+ const prefix = path.dirname(path.dirname(path.dirname(self)));
79
+ const link = real(path.join(prefix, "bin", NAME));
80
+ return link && link === real(path.join(self, "bin/cli.js")) ? prefix : null;
81
+ }
82
+
83
+ /** 怎么装的:npm 全局安装才能在线升级 */
84
+ async function installKind(): Promise<"npm-global" | "source" | "unknown"> {
85
+ if (fs.existsSync(path.join(root, ".git"))) return "source";
86
+ return globalPrefix() ? "npm-global" : "unknown";
87
+ }
88
+
89
+ let latestCache: { at: number; version: string | null; error?: string } | null = null;
90
+
91
+ /** npm 上的最新版。用 npm view 而不是直接请求 registry.npmjs.org:用户配的镜像源(npmmirror 等)照样生效 */
92
+ async function latestVersion(refresh: boolean): Promise<{ version: string | null; error?: string }> {
93
+ if (!refresh && latestCache && Date.now() - latestCache.at < 30 * 60_000) return latestCache;
94
+ const r = await run(npmBin(), ["view", NAME, "version"], 30_000);
95
+ const version = r.code === 0 ? r.out.split("\n").find((l) => /^\d+\.\d+\.\d+/.test(l.trim()))?.trim() : undefined;
96
+ latestCache = version ? { at: Date.now(), version } : { at: Date.now(), version: null, error: r.out.slice(-300) || "查询失败" };
97
+ return latestCache;
98
+ }
99
+
100
+ /** "0.2.10" > "0.2.9" */
101
+ export function newer(a: string, b: string): boolean {
102
+ const pa = a.split(".").map((n) => parseInt(n, 10) || 0);
103
+ const pb = b.split(".").map((n) => parseInt(n, 10) || 0);
104
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
105
+ if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
106
+ }
107
+ return false;
108
+ }
109
+
110
+ export type VersionInfo = {
111
+ version: string;
112
+ latest: string | null;
113
+ latestError?: string;
114
+ updateAvailable: boolean;
115
+ installKind: "npm-global" | "source" | "unknown";
116
+ service: "launchd" | "systemd" | null;
117
+ canUpgrade: boolean;
118
+ /** 不能在线升级时说明原因和手动办法 */
119
+ reason?: string;
120
+ upgrading: boolean;
121
+ };
122
+
123
+ let upgrading = false;
124
+
125
+ export async function versionInfo(refresh = false): Promise<VersionInfo> {
126
+ const [kind, latest] = await Promise.all([installKind(), latestVersion(refresh)]);
127
+ const updateAvailable = !!latest.version && newer(latest.version, RUNNING_VERSION);
128
+ const reason =
129
+ kind === "source"
130
+ ? "从源码运行的,用 git pull 更新后重启"
131
+ : kind === "unknown"
132
+ ? "不是用 npm install -g 装的,没法在线升级"
133
+ : undefined;
134
+ return {
135
+ version: RUNNING_VERSION,
136
+ latest: latest.version,
137
+ ...(latest.error ? { latestError: latest.error } : {}),
138
+ updateAvailable,
139
+ installKind: kind,
140
+ service: serviceManager(),
141
+ canUpgrade: kind === "npm-global",
142
+ ...(reason ? { reason } : {}),
143
+ upgrading,
144
+ };
145
+ }
146
+
147
+ /**
148
+ * 升级到最新版。跑在 launchd / systemd 下的,回完话就退出,由它们拉起新版本;
149
+ * 前台手动跑的(agent-loft start)没人拉起,只能请用户自己重启。
150
+ */
151
+ export async function upgrade(): Promise<{ from: string; to: string; restarting: boolean; output: string }> {
152
+ if (upgrading) throw new Error("正在升级,稍等");
153
+ const info = await versionInfo(true);
154
+ if (!info.canUpgrade) throw new Error(info.reason ?? "没法在线升级");
155
+ if (!info.latest) throw new Error(`查不到最新版本:${info.latestError ?? ""}`);
156
+ if (!info.updateAvailable) throw new Error(`已经是最新版本 ${RUNNING_VERSION}`);
157
+ upgrading = true;
158
+ try {
159
+ // 明确指定 prefix:装回原来那个位置,不受当前 npm 配置影响
160
+ const prefix = globalPrefix()!;
161
+ const r = await run(npmBin(), ["install", "-g", "--prefix", prefix, `${NAME}@${info.latest}`, "--no-audit", "--no-fund"], 10 * 60_000);
162
+ const installed = readPackage().version;
163
+ if (r.code !== 0 || installed !== info.latest) {
164
+ throw new Error(`npm install 失败:${r.out.slice(-600) || `退出码 ${r.code}`}`);
165
+ }
166
+ const restarting = !!info.service;
167
+ if (restarting) {
168
+ console.log(`[升级] 已装好 ${installed},退出进程,由 ${info.service} 拉起新版本`);
169
+ setTimeout(() => process.exit(0), 1500).unref();
170
+ }
171
+ return { from: RUNNING_VERSION, to: installed, restarting, output: r.out.slice(-2000) };
172
+ } finally {
173
+ upgrading = false;
174
+ }
175
+ }
176
+
177
+ /** 在服务端这台机器上安装 Codex:有 Homebrew 用它,否则用 npm。登录要在浏览器里点,这里不管。 */
178
+ export async function installCodex(): Promise<{ ok: boolean; via: string; output: string }> {
179
+ const brew = ["/opt/homebrew/bin/brew", "/usr/local/bin/brew"].find((p) => fs.existsSync(p));
180
+ const r =
181
+ process.platform === "darwin" && brew
182
+ ? await run(brew, ["install", "--cask", "codex"], 10 * 60_000)
183
+ : await run(npmBin(), ["install", "-g", "@openai/codex", "--no-audit", "--no-fund"], 10 * 60_000);
184
+ return { ok: r.code === 0, via: process.platform === "darwin" && brew ? "Homebrew" : "npm", output: r.out.slice(-2000) };
185
+ }
186
+
187
+ /**
188
+ * 装好 Codex 之后:配置里的 codex(默认就是 "codex")在服务的 PATH 里找不到的话,
189
+ * 换成刚装好的那个的绝对路径。改配置会让 codex app-server 按新路径重拉。
190
+ */
191
+ export function useInstalledCodex(): void {
192
+ const cfg = config();
193
+ if (spawnSync(cfg.agents.codex.path, ["--version"], { env: npmEnv(), timeout: 10_000 }).status === 0) return;
194
+ const found = [path.join(path.dirname(process.execPath), "codex"), "/opt/homebrew/bin/codex", "/usr/local/bin/codex"].find((p) => fs.existsSync(p));
195
+ if (!found) return;
196
+ const next = structuredClone(cfg);
197
+ next.agents.codex.path = found;
198
+ saveConfig(next);
199
+ console.log(`[codex] 已装好,路径改成 ${found}`);
200
+ }
package/src/main.ts CHANGED
@@ -5,6 +5,7 @@ import { flushStore, getConversation, isExpired } from "./core/store.js";
5
5
  import { sweepUploads } from "./core/uploads.js";
6
6
  import { ClaudeDriver } from "./agents/claude/driver.js";
7
7
  import { CodexDriver } from "./agents/codex/driver.js";
8
+ import { agentHealth } from "./agents/health.js";
8
9
  import { startFeishu, stopFeishu } from "./channels/feishu/index.js";
9
10
  import { startWeb, stopWeb } from "./channels/web/server.js";
10
11
  import { home } from "./home.js";
@@ -40,6 +41,8 @@ async function main(): Promise<void> {
40
41
 
41
42
  startFeishu();
42
43
  if (config().web.enabled) await startWeb();
44
+ // 先查一遍 agent 装没装、登没登录,网页打开时新对话里就只列出能用的
45
+ void agentHealth().catch(() => {});
43
46
 
44
47
  console.log(`[启动] workspace=${config().workspaceRoot}`);
45
48
  if (config().workspaceRoot === process.cwd()) {
package/web/app.js CHANGED
@@ -260,6 +260,14 @@ const S = {
260
260
 
261
261
  const ui = {};
262
262
 
263
+ /**
264
+ * 运行在 macOS 桌面端(agent-loft-desktop)里时,由它的 preload 注入。
265
+ * 桌面端收不到 Web Push,系统通知、Dock 角标、菜单栏的待处理数都交给它。
266
+ */
267
+ const desktop = window.agentLoftDesktop ?? null;
268
+ // 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
269
+ if (desktop) document.documentElement.classList.add("in-desktop");
270
+
263
271
  // ---------- WebSocket ----------
264
272
 
265
273
  const WS = {
@@ -445,6 +453,12 @@ function onWsMessage(msg) {
445
453
  case "ix":
446
454
  upsertInteraction(msg.interaction);
447
455
  return;
456
+ case "notice":
457
+ // 正看着这条会话就不打扰;普通网页靠 Web Push,这里只给桌面端
458
+ if (!desktop) return;
459
+ if (document.visibilityState === "visible" && document.hasFocus() && msg.key === S.current) return;
460
+ desktop.notify({ key: msg.key, title: msg.title, body: msg.body, tag: msg.tag });
461
+ return;
448
462
  }
449
463
  }
450
464
 
@@ -516,9 +530,10 @@ async function boot() {
516
530
  S.quotaRefreshMin = data.quotaRefreshMin ?? 10;
517
531
  S.convs = new Map(data.conversations.map((c) => [c.key, c]));
518
532
  S.pending = new Map(data.pending.map((i) => [i.id, i]));
519
- const enabled = S.agents.filter((a) => a.enabled).map((a) => a.id);
533
+ const enabled = usableAgents().map((a) => a.id);
520
534
  if (!S.draftAgent || !enabled.includes(S.draftAgent)) S.draftAgent = enabled[0] ?? null;
521
535
  renderLayout();
536
+ reportDesktop();
522
537
  WS.connect();
523
538
  void loadQuota();
524
539
  const fromHash = decodeURIComponent(location.hash.slice(1));
@@ -527,6 +542,10 @@ async function boot() {
527
542
  if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
528
543
  else if (latest) await openConversation(latest.key);
529
544
  else newDraft();
545
+ // 点了桌面端的系统通知 / 菜单栏里的待处理项:跳到对应会话
546
+ desktop?.onOpen((key) => {
547
+ if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
548
+ });
530
549
  if ("serviceWorker" in navigator) {
531
550
  navigator.serviceWorker.register("/sw.js").catch(() => {});
532
551
  clearAppBadge();
@@ -660,6 +679,7 @@ async function loadQuota() {
660
679
  const data = await api("/api/quota");
661
680
  quotaState.data = data.quotas;
662
681
  quotaState.at = Date.now();
682
+ desktop?.setQuota(data.quotas);
663
683
  } catch {
664
684
  // 保留旧数据
665
685
  } finally {
@@ -829,6 +849,7 @@ function scheduleSidebar() {
829
849
  requestAnimationFrame(() => {
830
850
  sidebarScheduled = false;
831
851
  renderSidebar();
852
+ reportDesktop();
832
853
  });
833
854
  }
834
855
 
@@ -923,6 +944,16 @@ let autoOpenedFor = null;
923
944
  const RECENT_LIMIT = 10;
924
945
  const PROJECT_LIMIT = 5;
925
946
 
947
+ /** 待处理的授权 / 提问和正在跑的会话,报给桌面端显示在菜单栏里 */
948
+ function reportDesktop() {
949
+ if (!desktop) return;
950
+ const pending = [...S.pending.values()]
951
+ .filter((i) => !i.settled)
952
+ .map((i) => ({ id: i.id, key: i.convKey, title: i.spec.title, conv: convTitle(S.convs.get(i.convKey)) }));
953
+ const busy = [...S.convs.values()].filter((c) => c.busy).length;
954
+ desktop.setState({ pending, busy });
955
+ }
956
+
926
957
  function renderSidebar() {
927
958
  if (!ui.convList) return;
928
959
  const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
@@ -1084,8 +1115,18 @@ function currentCwd() {
1084
1115
  return c ? c.cwd : (S.draftCwd ?? S.workspaceRoot ?? "");
1085
1116
  }
1086
1117
 
1118
+ /**
1119
+ * 新对话能选的 agent:启用了、并且服务端查过是装好登录了的(available 为 false 的不出现)。
1120
+ * 一个能用的都没有时退回到全部启用的,至少能看到报错,而不是什么都选不了。
1121
+ */
1122
+ function usableAgents() {
1123
+ const enabled = S.agents.filter((a) => a.enabled);
1124
+ const usable = enabled.filter((a) => a.available !== false);
1125
+ return usable.length ? usable : enabled;
1126
+ }
1127
+
1087
1128
  function pickAgentMenu(anchor) {
1088
- const allowed = S.agents.filter((a) => a.enabled).map((a) => a.id);
1129
+ const allowed = usableAgents().map((a) => a.id);
1089
1130
  if (allowed.length < 2) return;
1090
1131
  if (ui.menu?.dataset.for === "agent") return closeMenu();
1091
1132
  const pop = showPopover(anchor, { align: "left" });
@@ -1176,7 +1217,7 @@ function renderContext() {
1176
1217
  if (!ui.ctxBar) return;
1177
1218
  const c = S.current ? S.convs.get(S.current) : null;
1178
1219
  const cwd = currentCwd();
1179
- const multi = S.agents.filter((a) => a.enabled).length > 1;
1220
+ const multi = usableAgents().length > 1;
1180
1221
  // 会话里不能换 agent(两个 agent 的会话互不相通),只显示;新对话里可以选
1181
1222
  const agentId = c ? c.agent : S.draftAgent;
1182
1223
  const agentEl = c || !multi
@@ -1408,7 +1449,7 @@ function newDraft() {
1408
1449
  }
1409
1450
 
1410
1451
  function renderEmpty() {
1411
- const enabled = S.agents.filter((a) => a.enabled);
1452
+ const enabled = usableAgents();
1412
1453
  const cwd = S.draftCwd ?? S.workspaceRoot ?? "";
1413
1454
  fill(ui.msgInner,
1414
1455
  h(
@@ -1838,7 +1879,8 @@ function upsertInteraction(ix) {
1838
1879
  }
1839
1880
 
1840
1881
  function notifyIfHidden(ix) {
1841
- if (document.visibilityState === "visible" || !("Notification" in window)) return;
1882
+ // 桌面端由服务端的 notice 统一弹,这里再弹就重复了
1883
+ if (desktop || document.visibilityState === "visible" || !("Notification" in window)) return;
1842
1884
  if (Notification.permission === "granted") {
1843
1885
  try {
1844
1886
  new Notification("Agent Loft 需要你处理", { body: ix.spec.title, tag: ix.id });
@@ -2352,7 +2394,7 @@ function oneLine(text, max = 80) {
2352
2394
 
2353
2395
  function currentAgentId() {
2354
2396
  const c = S.current ? S.convs.get(S.current) : null;
2355
- return c?.agent ?? S.draftAgent ?? S.agents.find((a) => a.enabled)?.id ?? "claude";
2397
+ return c?.agent ?? S.draftAgent ?? usableAgents()[0]?.id ?? "claude";
2356
2398
  }
2357
2399
 
2358
2400
  async function loadCommands(agent) {
@@ -2563,14 +2605,14 @@ async function switchAgent(id) {
2563
2605
  await doAction("agent", id);
2564
2606
  }
2565
2607
 
2566
- function modal({ title, body, foot, wide }) {
2608
+ function modal({ title, body, foot, wide, tall }) {
2567
2609
  const close = () => overlay.remove();
2568
2610
  const overlay = h(
2569
2611
  "div",
2570
2612
  { class: "overlay", onclick: (e) => e.target === overlay && close() },
2571
2613
  h(
2572
2614
  "div",
2573
- { class: `modal${wide ? " wide" : ""}` },
2615
+ { class: `modal${wide ? " wide" : ""}${tall ? " tall" : ""}` },
2574
2616
  h("div", { class: "modal-head" }, h("h3", {}, title), h("button", { class: "icon-btn", onclick: close }, icon("x"))),
2575
2617
  body,
2576
2618
  foot ?? null,
@@ -3015,7 +3057,8 @@ async function deleteConv(key = S.current) {
3015
3057
  async function openSettings() {
3016
3058
  const nav = h("div", { class: "settings-nav" });
3017
3059
  const panel = h("div", { class: "settings-body" });
3018
- const close = modal({ title: "设置", wide: true, body: h("div", { class: "settings" }, nav, panel) });
3060
+ // 设置弹窗高度固定,切标签页时不跟着内容一伸一缩
3061
+ const close = modal({ title: "设置", wide: true, tall: true, body: h("div", { class: "settings" }, nav, panel) });
3019
3062
  let cfg;
3020
3063
  try {
3021
3064
  cfg = await api("/api/config");
@@ -3047,6 +3090,7 @@ async function openSettings() {
3047
3090
  function pushSupport() {
3048
3091
  const ios = /iPhone|iPad|iPod/.test(navigator.userAgent);
3049
3092
  const standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
3093
+ if (desktop) return { ok: false, why: "桌面端会直接弹出 macOS 系统通知,不需要在这里开启。" };
3050
3094
  if (!window.isSecureContext) return { ok: false, why: "通知需要 HTTPS 访问(localhost 除外)。请通过 HTTPS 的反向代理地址打开。" };
3051
3095
  if (ios && !standalone) {
3052
3096
  return { ok: false, why: "iPhone / iPad 上要先在 Safari 里点「分享 → 添加到主屏幕」,再从桌面图标打开,才能开启通知(需 iOS 16.4+)。" };
@@ -3197,7 +3241,61 @@ function field(label, input, hint) {
3197
3241
  return h("label", { class: "field" }, h("span", {}, label), input, hint ? h("small", {}, hint) : null);
3198
3242
  }
3199
3243
 
3244
+ /** 服务端版本:有新版就能一键升级(npm install -g,系统服务下自动重启) */
3245
+ function serverVersionSection() {
3246
+ const box = h("div", { class: "section" });
3247
+ const load = async (refresh) => {
3248
+ fill(box, h("h4", {}, "服务端版本", h("span", { class: "grow" }), h("span", { class: "spinner" })));
3249
+ let v;
3250
+ try {
3251
+ v = await api(`/api/server/version${refresh ? "?refresh=1" : ""}`);
3252
+ } catch (err) {
3253
+ fill(box, h("h4", {}, "服务端版本"), h("div", { class: "error-text" }, err.message));
3254
+ return;
3255
+ }
3256
+ const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "检查更新");
3257
+ const status = v.updateAvailable
3258
+ ? `有新版本 ${v.latest}`
3259
+ : v.latest
3260
+ ? "已是最新版本"
3261
+ : `查不到最新版本${v.latestError ? `:${v.latestError}` : ""}`;
3262
+ const upgradeBtn =
3263
+ v.updateAvailable && v.canUpgrade
3264
+ ? h(
3265
+ "button",
3266
+ {
3267
+ class: "btn small primary",
3268
+ onclick: async () => {
3269
+ upgradeBtn.disabled = true;
3270
+ upgradeBtn.textContent = "正在升级…";
3271
+ try {
3272
+ const r = await api("/api/server/upgrade", { method: "POST" });
3273
+ toast(r.restarting ? `已升级到 ${r.to},服务正在重启,页面会自动重连` : `已升级到 ${r.to},重启 agent-loft 后生效`, 6000);
3274
+ void load(false);
3275
+ } catch (err) {
3276
+ toast(`升级失败:${err.message}`, 6000);
3277
+ upgradeBtn.disabled = false;
3278
+ upgradeBtn.textContent = "升级";
3279
+ }
3280
+ },
3281
+ },
3282
+ "升级",
3283
+ )
3284
+ : null;
3285
+ fill(
3286
+ box,
3287
+ h("h4", {}, "服务端版本", h("span", { class: "grow" }), upgradeBtn, recheck),
3288
+ h("div", { class: "health-meta" }, `当前 ${v.version} · ${status}`),
3289
+ v.updateAvailable && !v.canUpgrade && v.reason ? h("div", { class: "health-meta" }, v.reason) : null,
3290
+ v.updateAvailable && v.canUpgrade && !v.service ? h("div", { class: "health-meta" }, "agent-loft 是在前台手动运行的,升级后要自己重启一下。") : null,
3291
+ );
3292
+ };
3293
+ void load(false);
3294
+ return box;
3295
+ }
3296
+
3200
3297
  function generalTab(cfg, panel) {
3298
+ panel.append(serverVersionSection());
3201
3299
  const root = h("input", { value: cfg.workspaceRoot });
3202
3300
  const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
3203
3301
  const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
@@ -3678,7 +3776,93 @@ function modelChecklist(agent, selected) {
3678
3776
  };
3679
3777
  }
3680
3778
 
3779
+ /** Agent 运行环境:装没装、登没登录;没好的给出在终端里执行的命令 */
3780
+ function agentHealthSection() {
3781
+ const box = h("div", { class: "section" });
3782
+ const load = async (refresh) => {
3783
+ const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "重新检查");
3784
+ fill(box, h("h4", {}, "运行环境", h("span", { class: "grow" }), h("span", { class: "spinner" })));
3785
+ let data;
3786
+ try {
3787
+ data = await api(`/api/agents/health${refresh ? "?refresh=1" : ""}`);
3788
+ } catch (err) {
3789
+ fill(box, h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck), h("div", { class: "error-text" }, err.message));
3790
+ return;
3791
+ }
3792
+ // 体检结果顺手同步到新对话的 agent 列表:刚装好 / 登录好的立刻能选,不行的立刻消失
3793
+ for (const a of data.agents) {
3794
+ const known = S.agents.find((x) => x.id === a.id);
3795
+ if (known) known.available = a.installed && a.loggedIn !== false;
3796
+ }
3797
+ if (!usableAgents().some((a) => a.id === S.draftAgent)) S.draftAgent = usableAgents()[0]?.id ?? null;
3798
+ if (!S.current) renderEmpty();
3799
+ fill(
3800
+ box,
3801
+ h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck),
3802
+ ...data.agents.map((a) => {
3803
+ const ok = a.installed && a.loggedIn === true;
3804
+ const state = !a.installed ? "未安装" : a.loggedIn === false ? "未登录" : a.loggedIn === null ? "无法确认" : "正常";
3805
+ return h(
3806
+ "div",
3807
+ { class: "health-row" },
3808
+ h(
3809
+ "div",
3810
+ { class: "health-head" },
3811
+ h("span", { class: `health-dot ${ok ? "ok" : "bad"}` }),
3812
+ h("b", {}, a.label),
3813
+ h("span", { class: "health-state" }, state),
3814
+ a.enabled ? null : h("span", { class: "health-off" }, "(未启用)"),
3815
+ ),
3816
+ a.account || a.version ? h("div", { class: "health-meta" }, [a.account, a.version].filter(Boolean).join(" · ")) : null,
3817
+ a.problem ? h("div", { class: "health-meta" }, a.problem) : null,
3818
+ a.fix
3819
+ ? h(
3820
+ "div",
3821
+ { class: "health-fix" },
3822
+ h("div", {}, `${a.fix.title}:在运行 agent-loft 的机器上打开终端执行${a.fix.commands.length > 1 ? "(任选一条)" : ""}`),
3823
+ a.fix.commands.map((cmd) => {
3824
+ const copy = h("button", { class: "btn small", onclick: () => copyText(cmd, copy) }, "复制");
3825
+ return h("div", { class: "health-cmd" }, h("code", {}, cmd), copy);
3826
+ }),
3827
+ a.fix.note ? h("small", {}, a.fix.note) : null,
3828
+ a.id === "codex" && a.fix.action === "install" ? installCodexButton(() => load(true)) : null,
3829
+ )
3830
+ : null,
3831
+ );
3832
+ }),
3833
+ );
3834
+ };
3835
+ void load(false);
3836
+ return box;
3837
+ }
3838
+
3839
+ /** 直接在服务端那台机器上装 Codex(Homebrew 或 npm),装完重新检查 */
3840
+ function installCodexButton(after) {
3841
+ const btn = h(
3842
+ "button",
3843
+ {
3844
+ class: "btn small primary",
3845
+ onclick: async () => {
3846
+ btn.disabled = true;
3847
+ btn.textContent = "正在安装,可能要几分钟…";
3848
+ try {
3849
+ const r = await api("/api/agents/codex/install", { method: "POST" });
3850
+ toast(r.ok ? `已用 ${r.via} 装好 Codex` : `安装失败:${r.output.slice(-200)}`, 6000);
3851
+ after();
3852
+ } catch (err) {
3853
+ toast(`安装失败:${err.message}`, 6000);
3854
+ btn.disabled = false;
3855
+ btn.textContent = "直接在服务器上安装";
3856
+ }
3857
+ },
3858
+ },
3859
+ "直接在服务器上安装",
3860
+ );
3861
+ return h("div", {}, btn);
3862
+ }
3863
+
3681
3864
  function agentsTab(cfg, panel) {
3865
+ panel.append(agentHealthSection());
3682
3866
  const a = structuredClone(cfg.agents);
3683
3867
  const claudeOn = h("input", { type: "checkbox", checked: a.claude.enabled });
3684
3868
  const claudePick = modelPicker("claude", a.claude.defaultModel, a.claude.defaultEffort, "跟随 Claude Code 默认");
package/web/style.css CHANGED
@@ -2300,6 +2300,11 @@ button.ctx-item:hover {
2300
2300
  max-width: 860px;
2301
2301
  }
2302
2302
 
2303
+ /* 设置弹窗:固定高度 */
2304
+ .modal.tall {
2305
+ height: 86vh;
2306
+ }
2307
+
2303
2308
  .modal-head {
2304
2309
  display: flex;
2305
2310
  align-items: center;
@@ -2747,6 +2752,10 @@ button.list-item:hover {
2747
2752
  align-items: center;
2748
2753
  }
2749
2754
 
2755
+ .modal.tall {
2756
+ height: 92vh;
2757
+ }
2758
+
2750
2759
  .modal,
2751
2760
  .modal.wide {
2752
2761
  max-width: none;
@@ -3152,3 +3161,75 @@ button.list-item:hover {
3152
3161
  .lightbox-dl:hover {
3153
3162
  background: rgba(255, 255, 255, 0.26);
3154
3163
  }
3164
+
3165
+ /* 设置 → Agent → 运行环境 */
3166
+ .health-row {
3167
+ display: flex;
3168
+ flex-direction: column;
3169
+ gap: 6px;
3170
+ }
3171
+ .health-row + .health-row {
3172
+ border-top: 1px solid var(--border);
3173
+ padding-top: 12px;
3174
+ }
3175
+ .health-head {
3176
+ display: flex;
3177
+ align-items: center;
3178
+ gap: 8px;
3179
+ }
3180
+ .health-dot {
3181
+ width: 8px;
3182
+ height: 8px;
3183
+ border-radius: 50%;
3184
+ background: var(--danger);
3185
+ flex: none;
3186
+ }
3187
+ .health-dot.ok {
3188
+ background: var(--ok);
3189
+ }
3190
+ .health-state {
3191
+ color: var(--fg-soft);
3192
+ }
3193
+ .health-off,
3194
+ .health-meta {
3195
+ color: var(--fg-faint);
3196
+ font-size: 12px;
3197
+ }
3198
+ .health-fix {
3199
+ display: flex;
3200
+ flex-direction: column;
3201
+ gap: 6px;
3202
+ padding: 10px 12px;
3203
+ border-radius: 10px;
3204
+ background: var(--warn-bg);
3205
+ border: 1px solid var(--warn-border);
3206
+ font-size: 13px;
3207
+ }
3208
+ .health-fix small {
3209
+ color: var(--fg-soft);
3210
+ }
3211
+ .health-cmd {
3212
+ display: flex;
3213
+ align-items: center;
3214
+ gap: 8px;
3215
+ }
3216
+ .health-cmd code {
3217
+ flex: 1;
3218
+ min-width: 0;
3219
+ overflow-x: auto;
3220
+ white-space: nowrap;
3221
+ padding: 5px 8px;
3222
+ border-radius: 6px;
3223
+ background: var(--code-bg);
3224
+ font-size: 12px;
3225
+ }
3226
+
3227
+ /* 桌面端(Agent Loft for macOS)里:顶栏和侧栏顶部按住能拖动窗口,里面的按钮照常能点 */
3228
+ .in-desktop .topbar,
3229
+ .in-desktop .side-top {
3230
+ -webkit-app-region: drag;
3231
+ }
3232
+ .in-desktop .topbar :is(button, a, input, select, textarea, label, .conv-name),
3233
+ .in-desktop .side-top :is(button, a, input, select, textarea, label) {
3234
+ -webkit-app-region: no-drag;
3235
+ }