agent-loft 0.2.15 → 0.2.16

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-loft",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "type": "module",
5
5
  "description": "多个 agent 的工作台:把本机 Claude Code / Codex 放到一处统一管理,用飞书和 Web 页面随时驱动——会话、审批、模型切换都共用",
6
6
  "keywords": [
@@ -5,6 +5,7 @@
5
5
  //
6
6
  // 注意:SDK 把它标成了 EXPERIMENTAL,方法名以后会改。改了的话这里只会读失败,
7
7
  // /status 少几行而已,不影响别的。
8
+ import { accountKey } from "../../core/account-key.js";
8
9
  import type { QuotaInfo } from "../../core/types.js";
9
10
  import { cachedProbe, withProbe } from "./probe.js";
10
11
 
@@ -22,6 +23,8 @@ type LimitRow = {
22
23
  export type ClaudeUsage = {
23
24
  subscriptionType: string | null;
24
25
  rows: { label: string; short: string; percent: number; resetsAt: string | null; severity?: string }[];
26
+ /** 账号标识(见 core/account-key.ts);读不到账号信息时没有 */
27
+ account?: string;
25
28
  };
26
29
 
27
30
  function labelOf(row: LimitRow): string {
@@ -70,10 +73,16 @@ function normalize(res: {
70
73
  }
71
74
 
72
75
  async function fetchUsage(): Promise<ClaudeUsage | null> {
73
- const res = await withProbe((q) =>
74
- q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }),
76
+ // 账号信息顺带在同一个探针进程里问,不多起进程;读不到不影响额度
77
+ const [res, info] = await withProbe((q) =>
78
+ Promise.all([
79
+ q.usage_EXPERIMENTAL_MAY_CHANGE_DO_NOT_RELY_ON_THIS_API_YET({ skipBehaviors: true }),
80
+ q.accountInfo().catch(() => null),
81
+ ]),
75
82
  );
76
- return normalize(res as Parameters<typeof normalize>[0]);
83
+ const usage = normalize(res as Parameters<typeof normalize>[0]);
84
+ const account = info?.email ? accountKey("claude", info.email, info.organization) : undefined;
85
+ return usage && account ? { ...usage, account } : usage;
77
86
  }
78
87
 
79
88
  /**
@@ -107,6 +116,7 @@ export function toQuota(usage: ClaudeUsage | null): QuotaInfo | null {
107
116
  return { label: r.label, short: r.short, percent: r.percent, ...(Number.isNaN(at) ? {} : { resetsAt: at }) };
108
117
  }),
109
118
  ...(usage.rows.some((r) => r.percent >= 100) ? { reached: true } : {}),
119
+ ...(usage.account ? { account: usage.account } : {}),
110
120
  };
111
121
  }
112
122
 
@@ -4,6 +4,7 @@
4
4
  // 而agent-loft的 `/status` 是另一条命令(同名而已),本来只报会话和目录。
5
5
  // 用的人不会关心这个区别,他只知道「同样敲 /status,这边少了额度」。
6
6
  // 与其解释,不如把它显示出来 —— app-server 本来就给了。
7
+ import { accountKey } from "../../core/account-key.js";
7
8
  import type { QuotaInfo } from "../../core/types.js";
8
9
  import { appServer } from "./app-server.js";
9
10
 
@@ -47,6 +48,28 @@ export async function readRateLimits(): Promise<RateLimits | null> {
47
48
  return cache.value;
48
49
  }
49
50
 
51
+ type Account = { type: "chatgpt"; email: string | null; planType?: string | null } | { type: string } | null;
52
+
53
+ let accountCache: { at: number; value: string | undefined } | null = null;
54
+
55
+ /**
56
+ * 账号标识(见 core/account-key.ts):只有 ChatGPT 登录、拿得到邮箱时才有。
57
+ * app-server 常驻着,问一下很便宜;和额度同样缓存 60 秒,换了账号一分钟内就能反映出来。
58
+ */
59
+ export async function readAccountKey(): Promise<string | undefined> {
60
+ if (accountCache && Date.now() - accountCache.at < CACHE_MS) return accountCache.value;
61
+ let value: string | undefined;
62
+ try {
63
+ const res = await appServer.call<{ account?: Account }>("account/read", {});
64
+ const acc = res.account;
65
+ if (acc?.type === "chatgpt" && "email" in acc) value = accountKey("codex", acc.email, acc.planType);
66
+ } catch (err) {
67
+ console.error("[账号] 读取失败:", err instanceof Error ? err.message : err);
68
+ }
69
+ accountCache = { at: Date.now(), value };
70
+ return value;
71
+ }
72
+
50
73
  /** 300 → 「5h」,10080 → 「7d」 */
51
74
  function windowShort(mins: number): string {
52
75
  if (mins % (60 * 24) === 0) return `${mins / (60 * 24)}d`;
@@ -55,7 +78,7 @@ function windowShort(mins: number): string {
55
78
  }
56
79
 
57
80
  /** 侧栏用的结构化额度 */
58
- export function toQuota(limits: RateLimits | null): QuotaInfo | null {
81
+ export function toQuota(limits: RateLimits | null, account?: string): QuotaInfo | null {
59
82
  if (!limits) return null;
60
83
  const windows = [limits.primary, limits.secondary]
61
84
  .filter((w): w is UsageWindow => !!w)
@@ -73,6 +96,7 @@ export function toQuota(limits: RateLimits | null): QuotaInfo | null {
73
96
  windows,
74
97
  ...(extra ? { extra } : {}),
75
98
  ...(limits.rateLimitReachedType ? { reached: true } : {}),
99
+ ...(account ? { account } : {}),
76
100
  };
77
101
  }
78
102
 
@@ -2,7 +2,7 @@
2
2
  //
3
3
  // 不用 @openai/codex-sdk:它是 `codex exec --json` 的薄封装,没有任何审批回调。
4
4
  import { appServer } from "./app-server.js";
5
- import { describeRateLimits, readRateLimits, toQuota } from "./account.js";
5
+ import { describeRateLimits, readAccountKey, readRateLimits, toQuota } from "./account.js";
6
6
  import { diagnose } from "./errors.js";
7
7
  import { CodexSession, summarize } from "./session.js";
8
8
  import { config, onConfigChange } from "../../core/config.js";
@@ -209,7 +209,8 @@ export class CodexDriver implements AgentDriver {
209
209
  }
210
210
 
211
211
  async quota(): Promise<QuotaInfo | null> {
212
- return toQuota(await readRateLimits());
212
+ const [limits, account] = await Promise.all([readRateLimits(), readAccountKey()]);
213
+ return toQuota(limits, account);
213
214
  }
214
215
 
215
216
  diagnose(err: unknown) {
@@ -565,8 +565,10 @@ export async function handleApi(req: IncomingMessage, res: ServerResponse, url:
565
565
  return;
566
566
  }
567
567
  if (p === "/api/quota" && method === "GET") {
568
+ // ?agent=claude,codex:只问这几个。桌面端连着多台同账号的服务器时,同一个账号只在一台上问
569
+ const only = url.searchParams.get("agent")?.split(",").filter(Boolean);
568
570
  const results = await Promise.all(
569
- hub.enabledAgents().map(async (d) => {
571
+ hub.enabledAgents().filter((d) => !only || only.includes(d.id)).map(async (d) => {
570
572
  if (!d.quota) return null;
571
573
  try {
572
574
  const quota = await Promise.race([
@@ -0,0 +1,13 @@
1
+ // 额度对应的账号标识:桌面端连着多台服务器时,靠它认出「这几台登的是同一个账号」,
2
+ // 同一个账号只问一台,结果分给其他几台(额度是跟着账号走的,问哪台都一样)。
3
+ //
4
+ // 只给哈希、不给原文:接口返回里不出现邮箱。不同服务器上同一个账号算出来的值相同,
5
+ // 这正是要的效果,所以不加盐。
6
+ import { createHash } from "node:crypto";
7
+
8
+ /** 没有能认出账号的信息(API key、第三方云之类)时返回 undefined,桌面端就照旧逐台去问 */
9
+ export function accountKey(agent: string, ...parts: (string | null | undefined)[]): string | undefined {
10
+ if (!parts[0]) return undefined;
11
+ const text = [agent, ...parts.map((p) => (p ?? "").trim().toLowerCase())].join("\n");
12
+ return createHash("sha256").update(text).digest("hex").slice(0, 16);
13
+ }
package/src/core/types.ts CHANGED
@@ -116,6 +116,11 @@ export type QuotaInfo = {
116
116
  extra?: string;
117
117
  /** 已撞上限 */
118
118
  reached?: boolean;
119
+ /**
120
+ * 账号标识(邮箱等信息的哈希,见 core/account-key.ts)。多台服务器登的同一个账号时相同,
121
+ * 桌面端据此只问其中一台。认不出账号时不给。
122
+ */
123
+ account?: string;
119
124
  };
120
125
 
121
126
  // ---------- agent ----------
package/web/app.js CHANGED
@@ -265,6 +265,8 @@ const ui = {};
265
265
  * 桌面端收不到 Web Push,系统通知、Dock 角标、菜单栏的待处理数都交给它。
266
266
  */
267
267
  const desktop = window.agentLoftDesktop ?? null;
268
+ /** 新版桌面端:额度由它统一查好推过来,页面自己不查(老版本桌面端没有 onQuota,照旧自己查) */
269
+ const desktopQuota = typeof desktop?.onQuota === "function";
268
270
  // 桌面端里:顶栏、侧栏顶部可以按住拖动窗口(样式见 style.css 的 .in-desktop)
269
271
  if (desktop) document.documentElement.classList.add("in-desktop");
270
272
 
@@ -535,7 +537,8 @@ async function boot() {
535
537
  renderLayout();
536
538
  reportDesktop();
537
539
  WS.connect();
538
- void loadQuota();
540
+ if (desktopQuota) desktop.onQuota(applyDesktopQuota);
541
+ else void loadQuota();
539
542
  const fromHash = decodeURIComponent(location.hash.slice(1));
540
543
  // 地址里带了会话就打开它;否则打开「最近」里最近活跃的那个对话,一个都没有才进新对话页
541
544
  const latest = [...S.convs.values()].filter((c) => !c.hidden).sort((a, b) => b.activeAt - a.activeAt)[0];
@@ -666,12 +669,19 @@ function stopMarquee() {
666
669
  // - 有会话跑完一轮:10 秒后拉;若那时服务端缓存还没过期,就推迟到过期之后,保证拿到新数据;
667
670
  // - 切回前台:距上次超过一个刷新间隔就拉;
668
671
  // - 点一下额度区域:立刻拉(可能拿到 60 秒内的缓存)。
672
+ // 在桌面端里(desktopQuota)以上全都不做:桌面端统一去查(同一个账号的多台服务器只问一台),
673
+ // 查好推过来;点一下额度区域是请桌面端去刷新。
669
674
 
670
675
  const quotaState = { data: null, at: 0, loading: false, timer: 0, idle: 0 };
671
676
  const QUOTA_SERVER_CACHE = 60_000;
672
677
  const quotaInterval = () => (S.quotaRefreshMin ?? 10) * 60_000;
673
678
 
674
679
  async function loadQuota() {
680
+ if (desktopQuota) {
681
+ ui.quota?.classList.add("loading");
682
+ desktop.refreshQuota();
683
+ return;
684
+ }
675
685
  if (quotaState.loading) return;
676
686
  quotaState.loading = true;
677
687
  ui.quota?.classList.add("loading");
@@ -690,10 +700,20 @@ async function loadQuota() {
690
700
  }
691
701
  }
692
702
 
703
+ /** 桌面端推来的额度。查失败(error)时保留旧数据 */
704
+ function applyDesktopQuota(payload) {
705
+ if (Array.isArray(payload?.quotas) && !payload.error) {
706
+ quotaState.data = payload.quotas;
707
+ quotaState.at = payload.at || Date.now();
708
+ }
709
+ ui.quota?.classList.remove("loading");
710
+ renderQuota();
711
+ }
712
+
693
713
  /** 空闲定时:每次拉完重新计时,所以刚因为跑完一轮拉过,就不会紧接着又拉 */
694
714
  function scheduleIdleQuota() {
695
715
  clearTimeout(quotaState.idle);
696
- if (!quotaInterval()) return;
716
+ if (desktopQuota || !quotaInterval()) return;
697
717
  quotaState.idle = setTimeout(() => {
698
718
  // 页面不可见就先不拉,等切回来再说
699
719
  if (document.visibilityState === "visible") void loadQuota();
@@ -702,13 +722,15 @@ function scheduleIdleQuota() {
702
722
 
703
723
  /** 一轮跑完:10 秒后拉;服务端缓存那时还没过期就推迟到过期之后 */
704
724
  function scheduleQuota() {
725
+ // 桌面端里:它从页面报的运行数看出跑完了一轮,自己会去查
726
+ if (desktopQuota) return;
705
727
  const wait = Math.max(10_000, quotaState.at + QUOTA_SERVER_CACHE + 1000 - Date.now());
706
728
  clearTimeout(quotaState.timer);
707
729
  quotaState.timer = setTimeout(() => void loadQuota(), wait);
708
730
  }
709
731
 
710
732
  document.addEventListener("visibilitychange", () => {
711
- if (document.visibilityState !== "visible" || !ui.quota || !quotaInterval()) return;
733
+ if (desktopQuota || document.visibilityState !== "visible" || !ui.quota || !quotaInterval()) return;
712
734
  if (Date.now() - quotaState.at >= quotaInterval()) void loadQuota();
713
735
  });
714
736
 
@@ -3308,7 +3330,13 @@ function generalTab(cfg, panel) {
3308
3330
  field("工作目录(workspace)", root, "所有会话的默认目录,/cd 只能在它之内"),
3309
3331
  field("授权等待超时(分钟)", approval, "超时按拒绝处理"),
3310
3332
  field("会话闲置多久自动开新会话(小时)", ttl, "0 表示永不过期"),
3311
- field("额度自动刷新间隔(分钟)", quotaMin, "侧栏底部的额度,空闲时多久刷新一次;0 表示不自动刷新(跑完一轮、点击额度区域仍会刷新)"),
3333
+ field(
3334
+ "额度自动刷新间隔(分钟)",
3335
+ quotaMin,
3336
+ desktopQuota
3337
+ ? "在桌面端里额度由桌面端统一刷新,以桌面端设置里的间隔为准;这里只对浏览器打开的页面生效"
3338
+ : "侧栏底部的额度,空闲时多久刷新一次;0 表示不自动刷新(跑完一轮、点击额度区域仍会刷新)",
3339
+ ),
3312
3340
  h(
3313
3341
  "div",
3314
3342
  { class: "row" },