agent-loft 0.2.13 → 0.2.14

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.14",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -0,0 +1,155 @@
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
+ export function invalidateHealth(): void {
139
+ cache = null;
140
+ }
141
+ let inflight: Promise<AgentHealth[]> | null = null;
142
+
143
+ /** 两个 agent 一起查,结果缓存一分钟;refresh 跳过缓存(点了「重新检查」) */
144
+ export function agentHealth(refresh = false): Promise<AgentHealth[]> {
145
+ if (!refresh && cache && Date.now() - cache.at < 60_000) return Promise.resolve(cache.value);
146
+ inflight ??= Promise.all([claudeHealth(), codexHealth()])
147
+ .then((value) => {
148
+ cache = { at: Date.now(), value };
149
+ return value;
150
+ })
151
+ .finally(() => {
152
+ inflight = null;
153
+ });
154
+ return inflight;
155
+ }
@@ -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 { 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";
@@ -539,6 +541,27 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
539
541
  return;
540
542
  }
541
543
  // 侧栏的账号额度:各 agent 自己缓存(60 秒),这里并发问一遍,单个失败 / 超时不影响别的
544
+ if (p === "/api/server/version" && method === "GET") {
545
+ sendJson(res, 200, await versionInfo(url.searchParams.get("refresh") === "1"));
546
+ return;
547
+ }
548
+ if (p === "/api/server/upgrade" && method === "POST") {
549
+ sendJson(res, 200, await upgrade().catch((err) => {
550
+ throw new HttpError(400, err instanceof Error ? err.message : String(err));
551
+ }));
552
+ return;
553
+ }
554
+ if (p === "/api/agents/codex/install" && method === "POST") {
555
+ const result = await installCodex();
556
+ if (result.ok) useInstalledCodex();
557
+ invalidateHealth();
558
+ sendJson(res, 200, result);
559
+ return;
560
+ }
561
+ if (p === "/api/agents/health" && method === "GET") {
562
+ sendJson(res, 200, { agents: await agentHealth(url.searchParams.get("refresh") === "1") });
563
+ return;
564
+ }
542
565
  if (p === "/api/quota" && method === "GET") {
543
566
  const results = await Promise.all(
544
567
  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/web/app.js CHANGED
@@ -260,6 +260,12 @@ 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
+
263
269
  // ---------- WebSocket ----------
264
270
 
265
271
  const WS = {
@@ -445,6 +451,12 @@ function onWsMessage(msg) {
445
451
  case "ix":
446
452
  upsertInteraction(msg.interaction);
447
453
  return;
454
+ case "notice":
455
+ // 正看着这条会话就不打扰;普通网页靠 Web Push,这里只给桌面端
456
+ if (!desktop) return;
457
+ if (document.visibilityState === "visible" && document.hasFocus() && msg.key === S.current) return;
458
+ desktop.notify({ key: msg.key, title: msg.title, body: msg.body, tag: msg.tag });
459
+ return;
448
460
  }
449
461
  }
450
462
 
@@ -519,6 +531,7 @@ async function boot() {
519
531
  const enabled = S.agents.filter((a) => a.enabled).map((a) => a.id);
520
532
  if (!S.draftAgent || !enabled.includes(S.draftAgent)) S.draftAgent = enabled[0] ?? null;
521
533
  renderLayout();
534
+ reportDesktop();
522
535
  WS.connect();
523
536
  void loadQuota();
524
537
  const fromHash = decodeURIComponent(location.hash.slice(1));
@@ -527,6 +540,10 @@ async function boot() {
527
540
  if (fromHash && S.convs.has(fromHash)) await openConversation(fromHash);
528
541
  else if (latest) await openConversation(latest.key);
529
542
  else newDraft();
543
+ // 点了桌面端的系统通知 / 菜单栏里的待处理项:跳到对应会话
544
+ desktop?.onOpen((key) => {
545
+ if (key && S.convs.has(key) && key !== S.current) void openConversation(key);
546
+ });
530
547
  if ("serviceWorker" in navigator) {
531
548
  navigator.serviceWorker.register("/sw.js").catch(() => {});
532
549
  clearAppBadge();
@@ -660,6 +677,7 @@ async function loadQuota() {
660
677
  const data = await api("/api/quota");
661
678
  quotaState.data = data.quotas;
662
679
  quotaState.at = Date.now();
680
+ desktop?.setQuota(data.quotas);
663
681
  } catch {
664
682
  // 保留旧数据
665
683
  } finally {
@@ -829,6 +847,7 @@ function scheduleSidebar() {
829
847
  requestAnimationFrame(() => {
830
848
  sidebarScheduled = false;
831
849
  renderSidebar();
850
+ reportDesktop();
832
851
  });
833
852
  }
834
853
 
@@ -923,6 +942,16 @@ let autoOpenedFor = null;
923
942
  const RECENT_LIMIT = 10;
924
943
  const PROJECT_LIMIT = 5;
925
944
 
945
+ /** 待处理的授权 / 提问和正在跑的会话,报给桌面端显示在菜单栏里 */
946
+ function reportDesktop() {
947
+ if (!desktop) return;
948
+ const pending = [...S.pending.values()]
949
+ .filter((i) => !i.settled)
950
+ .map((i) => ({ id: i.id, key: i.convKey, title: i.spec.title, conv: convTitle(S.convs.get(i.convKey)) }));
951
+ const busy = [...S.convs.values()].filter((c) => c.busy).length;
952
+ desktop.setState({ pending, busy });
953
+ }
954
+
926
955
  function renderSidebar() {
927
956
  if (!ui.convList) return;
928
957
  const askKeys = new Set([...S.pending.values()].filter((i) => !i.settled).map((i) => i.convKey));
@@ -1838,7 +1867,8 @@ function upsertInteraction(ix) {
1838
1867
  }
1839
1868
 
1840
1869
  function notifyIfHidden(ix) {
1841
- if (document.visibilityState === "visible" || !("Notification" in window)) return;
1870
+ // 桌面端由服务端的 notice 统一弹,这里再弹就重复了
1871
+ if (desktop || document.visibilityState === "visible" || !("Notification" in window)) return;
1842
1872
  if (Notification.permission === "granted") {
1843
1873
  try {
1844
1874
  new Notification("Agent Loft 需要你处理", { body: ix.spec.title, tag: ix.id });
@@ -3047,6 +3077,7 @@ async function openSettings() {
3047
3077
  function pushSupport() {
3048
3078
  const ios = /iPhone|iPad|iPod/.test(navigator.userAgent);
3049
3079
  const standalone = window.matchMedia("(display-mode: standalone)").matches || navigator.standalone === true;
3080
+ if (desktop) return { ok: false, why: "桌面端会直接弹出 macOS 系统通知,不需要在这里开启。" };
3050
3081
  if (!window.isSecureContext) return { ok: false, why: "通知需要 HTTPS 访问(localhost 除外)。请通过 HTTPS 的反向代理地址打开。" };
3051
3082
  if (ios && !standalone) {
3052
3083
  return { ok: false, why: "iPhone / iPad 上要先在 Safari 里点「分享 → 添加到主屏幕」,再从桌面图标打开,才能开启通知(需 iOS 16.4+)。" };
@@ -3197,7 +3228,60 @@ function field(label, input, hint) {
3197
3228
  return h("label", { class: "field" }, h("span", {}, label), input, hint ? h("small", {}, hint) : null);
3198
3229
  }
3199
3230
 
3231
+ /** 服务端版本:有新版就能一键升级(npm install -g,系统服务下自动重启) */
3232
+ function serverVersionSection() {
3233
+ const box = h("div", { class: "section" });
3234
+ const load = async (refresh) => {
3235
+ box.replaceChildren(h("h4", {}, "服务端版本", h("span", { class: "grow" }), h("span", { class: "spinner" })));
3236
+ let v;
3237
+ try {
3238
+ v = await api(`/api/server/version${refresh ? "?refresh=1" : ""}`);
3239
+ } catch (err) {
3240
+ box.replaceChildren(h("h4", {}, "服务端版本"), h("div", { class: "error-text" }, err.message));
3241
+ return;
3242
+ }
3243
+ const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "检查更新");
3244
+ const status = v.updateAvailable
3245
+ ? `有新版本 ${v.latest}`
3246
+ : v.latest
3247
+ ? "已是最新版本"
3248
+ : `查不到最新版本${v.latestError ? `:${v.latestError}` : ""}`;
3249
+ const upgradeBtn =
3250
+ v.updateAvailable && v.canUpgrade
3251
+ ? h(
3252
+ "button",
3253
+ {
3254
+ class: "btn small primary",
3255
+ onclick: async () => {
3256
+ upgradeBtn.disabled = true;
3257
+ upgradeBtn.textContent = "正在升级…";
3258
+ try {
3259
+ const r = await api("/api/server/upgrade", { method: "POST" });
3260
+ toast(r.restarting ? `已升级到 ${r.to},服务正在重启,页面会自动重连` : `已升级到 ${r.to},重启 agent-loft 后生效`, 6000);
3261
+ void load(false);
3262
+ } catch (err) {
3263
+ toast(`升级失败:${err.message}`, 6000);
3264
+ upgradeBtn.disabled = false;
3265
+ upgradeBtn.textContent = "升级";
3266
+ }
3267
+ },
3268
+ },
3269
+ "升级",
3270
+ )
3271
+ : null;
3272
+ box.replaceChildren(
3273
+ h("h4", {}, "服务端版本", h("span", { class: "grow" }), upgradeBtn, recheck),
3274
+ h("div", { class: "health-meta" }, `当前 ${v.version} · ${status}`),
3275
+ v.updateAvailable && !v.canUpgrade && v.reason ? h("div", { class: "health-meta" }, v.reason) : null,
3276
+ v.updateAvailable && v.canUpgrade && !v.service ? h("div", { class: "health-meta" }, "agent-loft 是在前台手动运行的,升级后要自己重启一下。") : null,
3277
+ );
3278
+ };
3279
+ void load(false);
3280
+ return box;
3281
+ }
3282
+
3200
3283
  function generalTab(cfg, panel) {
3284
+ panel.append(serverVersionSection());
3201
3285
  const root = h("input", { value: cfg.workspaceRoot });
3202
3286
  const approval = h("input", { type: "number", min: 1, value: Math.round(cfg.approvalTimeoutMs / 60000) });
3203
3287
  const ttl = h("input", { type: "number", min: 0, value: Math.round(cfg.sessionTtlMs / 3600000) });
@@ -3678,7 +3762,85 @@ function modelChecklist(agent, selected) {
3678
3762
  };
3679
3763
  }
3680
3764
 
3765
+ /** Agent 运行环境:装没装、登没登录;没好的给出在终端里执行的命令 */
3766
+ function agentHealthSection() {
3767
+ const box = h("div", { class: "section" });
3768
+ const load = async (refresh) => {
3769
+ const recheck = h("button", { class: "btn small", onclick: () => load(true) }, "重新检查");
3770
+ box.replaceChildren(h("h4", {}, "运行环境", h("span", { class: "grow" }), h("span", { class: "spinner" })));
3771
+ let data;
3772
+ try {
3773
+ data = await api(`/api/agents/health${refresh ? "?refresh=1" : ""}`);
3774
+ } catch (err) {
3775
+ box.replaceChildren(h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck), h("div", { class: "error-text" }, err.message));
3776
+ return;
3777
+ }
3778
+ box.replaceChildren(
3779
+ h("h4", {}, "运行环境", h("span", { class: "grow" }), recheck),
3780
+ ...data.agents.map((a) => {
3781
+ const ok = a.installed && a.loggedIn === true;
3782
+ const state = !a.installed ? "未安装" : a.loggedIn === false ? "未登录" : a.loggedIn === null ? "无法确认" : "正常";
3783
+ return h(
3784
+ "div",
3785
+ { class: "health-row" },
3786
+ h(
3787
+ "div",
3788
+ { class: "health-head" },
3789
+ h("span", { class: `health-dot ${ok ? "ok" : "bad"}` }),
3790
+ h("b", {}, a.label),
3791
+ h("span", { class: "health-state" }, state),
3792
+ a.enabled ? null : h("span", { class: "health-off" }, "(未启用)"),
3793
+ ),
3794
+ a.account || a.version ? h("div", { class: "health-meta" }, [a.account, a.version].filter(Boolean).join(" · ")) : null,
3795
+ a.problem ? h("div", { class: "health-meta" }, a.problem) : null,
3796
+ a.fix
3797
+ ? h(
3798
+ "div",
3799
+ { class: "health-fix" },
3800
+ h("div", {}, `${a.fix.title}:在运行 agent-loft 的机器上打开终端执行${a.fix.commands.length > 1 ? "(任选一条)" : ""}`),
3801
+ a.fix.commands.map((cmd) => {
3802
+ const copy = h("button", { class: "btn small", onclick: () => copyText(cmd, copy) }, "复制");
3803
+ return h("div", { class: "health-cmd" }, h("code", {}, cmd), copy);
3804
+ }),
3805
+ a.fix.note ? h("small", {}, a.fix.note) : null,
3806
+ a.id === "codex" && a.fix.action === "install" ? installCodexButton(() => load(true)) : null,
3807
+ )
3808
+ : null,
3809
+ );
3810
+ }),
3811
+ );
3812
+ };
3813
+ void load(false);
3814
+ return box;
3815
+ }
3816
+
3817
+ /** 直接在服务端那台机器上装 Codex(Homebrew 或 npm),装完重新检查 */
3818
+ function installCodexButton(after) {
3819
+ const btn = h(
3820
+ "button",
3821
+ {
3822
+ class: "btn small primary",
3823
+ onclick: async () => {
3824
+ btn.disabled = true;
3825
+ btn.textContent = "正在安装,可能要几分钟…";
3826
+ try {
3827
+ const r = await api("/api/agents/codex/install", { method: "POST" });
3828
+ toast(r.ok ? `已用 ${r.via} 装好 Codex` : `安装失败:${r.output.slice(-200)}`, 6000);
3829
+ after();
3830
+ } catch (err) {
3831
+ toast(`安装失败:${err.message}`, 6000);
3832
+ btn.disabled = false;
3833
+ btn.textContent = "直接在服务器上安装";
3834
+ }
3835
+ },
3836
+ },
3837
+ "直接在服务器上安装",
3838
+ );
3839
+ return h("div", {}, btn);
3840
+ }
3841
+
3681
3842
  function agentsTab(cfg, panel) {
3843
+ panel.append(agentHealthSection());
3682
3844
  const a = structuredClone(cfg.agents);
3683
3845
  const claudeOn = h("input", { type: "checkbox", checked: a.claude.enabled });
3684
3846
  const claudePick = modelPicker("claude", a.claude.defaultModel, a.claude.defaultEffort, "跟随 Claude Code 默认");
package/web/style.css CHANGED
@@ -3152,3 +3152,65 @@ button.list-item:hover {
3152
3152
  .lightbox-dl:hover {
3153
3153
  background: rgba(255, 255, 255, 0.26);
3154
3154
  }
3155
+
3156
+ /* 设置 → Agent → 运行环境 */
3157
+ .health-row {
3158
+ display: flex;
3159
+ flex-direction: column;
3160
+ gap: 6px;
3161
+ }
3162
+ .health-row + .health-row {
3163
+ border-top: 1px solid var(--border);
3164
+ padding-top: 12px;
3165
+ }
3166
+ .health-head {
3167
+ display: flex;
3168
+ align-items: center;
3169
+ gap: 8px;
3170
+ }
3171
+ .health-dot {
3172
+ width: 8px;
3173
+ height: 8px;
3174
+ border-radius: 50%;
3175
+ background: var(--danger);
3176
+ flex: none;
3177
+ }
3178
+ .health-dot.ok {
3179
+ background: var(--ok);
3180
+ }
3181
+ .health-state {
3182
+ color: var(--fg-soft);
3183
+ }
3184
+ .health-off,
3185
+ .health-meta {
3186
+ color: var(--fg-faint);
3187
+ font-size: 12px;
3188
+ }
3189
+ .health-fix {
3190
+ display: flex;
3191
+ flex-direction: column;
3192
+ gap: 6px;
3193
+ padding: 10px 12px;
3194
+ border-radius: 10px;
3195
+ background: var(--warn-bg);
3196
+ border: 1px solid var(--warn-border);
3197
+ font-size: 13px;
3198
+ }
3199
+ .health-fix small {
3200
+ color: var(--fg-soft);
3201
+ }
3202
+ .health-cmd {
3203
+ display: flex;
3204
+ align-items: center;
3205
+ gap: 8px;
3206
+ }
3207
+ .health-cmd code {
3208
+ flex: 1;
3209
+ min-width: 0;
3210
+ overflow-x: auto;
3211
+ white-space: nowrap;
3212
+ padding: 5px 8px;
3213
+ border-radius: 6px;
3214
+ background: var(--code-bg);
3215
+ font-size: 12px;
3216
+ }