@aiti-ai/aiti-agent-manager 0.0.0-stage → 0.2.9
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/AGENTS.md +51 -0
- package/LICENSE +21 -0
- package/README.md +38 -2
- package/agents-registry.test.mjs +108 -0
- package/agents-registry.ts +213 -0
- package/client/src/agent-manager-client.mjs +483 -0
- package/client/src/agent-manager-styles.mjs +172 -0
- package/dev-pack.mjs +246 -0
- package/dist/agent-manager-client.mjs +483 -0
- package/dist/agent-manager-styles.mjs +172 -0
- package/ext-icons-deploy.mjs +24 -0
- package/icons/bot.svg +1 -0
- package/index.test.mjs +234 -0
- package/index.ts +461 -0
- package/package.json +27 -4
- package/reconcile-deps.mjs +41 -0
- package/reload-instances.mjs +60 -0
- package/tools/deploy.mjs +51 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# aiti-agent-manager 扩展(部署目录直改指南)
|
|
2
|
+
|
|
3
|
+
## 结构
|
|
4
|
+
- 运行时文件平铺在本目录根(index.ts / core.cjs …,宿主 loader 按相对路径加载——**禁手改运行时平铺文件**,改 src/ 再 build)
|
|
5
|
+
- `src/` 源码(client/src + 服务端 ts/cjs)· `dist/` 构建产物(禁手改)· `tools/` 构建/部署脚本
|
|
6
|
+
- npm 包形态的扩展:本目录 package.json 含 version 与 aitiConfigs 声明
|
|
7
|
+
|
|
8
|
+
## 小改现场流程(改文案/样式/小逻辑)
|
|
9
|
+
1. 改 `src/` 下对应源码
|
|
10
|
+
2. `node tools/build.mjs`(client 有构建产物的扩展;纯服务端改动跳过)
|
|
11
|
+
3. `curl -X POST http://127.0.0.1:3001/api/extensions/reload`(dev;prod 3002;外部经 nginx 带 basePath:/dev、/mac、/mdev、/win、/wdev → `/xxx/api/extensions/reload`)
|
|
12
|
+
4. 浏览器刷新页面验证
|
|
13
|
+
|
|
14
|
+
## 大改回源码仓
|
|
15
|
+
- 源码仓:`extensions/aiti-agent-manager/`(git 管理,deploy 同步回本目录)
|
|
16
|
+
- 改完在源码仓跑 `node tools/deploy.mjs`(或仓库根 `node scripts/deploy-all.mjs --only aiti-agent-manager`)
|
|
17
|
+
|
|
18
|
+
## 数据目录(数据分层:config / cache)
|
|
19
|
+
- `ctx.configDir` 根级 = 配置与关键小状态(aiti-configs 备份范围;小、珍贵、人可读)
|
|
20
|
+
- `ctx.cacheDir`(= `<configDir>/cache/`)= 可删缓存:db/venv/日志/大文件;rm -rf 后扩展下次访问自动重建
|
|
21
|
+
- `ctx.dataDir` 已移除(访问即抛)——新代码禁用
|
|
22
|
+
- 本扩展白名单(永不为 cache 的配置文件)见 package.json `aitiConfigs.configFiles`
|
|
23
|
+
|
|
24
|
+
## 账号档(多账号备份/切换,2026-09-13)
|
|
25
|
+
- **单文件存储**:`<configDir>/agent-accounts.json`(0600)——所有 agent、所有账号档收在一个 JSON;
|
|
26
|
+
aiti-configs 备份时按 encrypt pattern 加密(`agent-accounts.json`,见其 protected/patterns)。
|
|
27
|
+
- **共享引擎**:`agent-accounts.ts` 是本目录对 `_shared/agent-accounts.ts` 的逐字节副本
|
|
28
|
+
(shared-copy-sync 测试钉一致性;改动先改 `_shared` 再 cp 副本)。
|
|
29
|
+
- **架构(二版):manager 只做框架,各家自报家门**——每家 agent 扩展 startup 里
|
|
30
|
+
`ctx.services.provide("<extId>.agent", descriptor)`(label/modes/fs 探测表/accountSlots/actions);
|
|
31
|
+
manager 遍历宿主 `registry.status()` + `services.get` 动态收集,**不写死任何一家**(未 provide 的扩展不出现)。
|
|
32
|
+
descriptor 契约见 `agents-registry.ts`;6 家 agent 扩展各自提供。
|
|
33
|
+
- **凭据槽位**:由**各家扩展 descriptor.accountSlots(ctx) 自己声明**(路径知识留在各家,可闭包 configDir);manager 只消费。
|
|
34
|
+
- **静态 ui 插入点**:`ui.toolbar`(`aiti-agent-manager-panel`,icons/bot.svg)+ `ui.dialogs`(与 client registerItems id 一一对应;extman 展示/懒加载对账用)。
|
|
35
|
+
- **current 判定**:内容签名(sha256)+ 账号身份(identity)双判定——token 自动刷新(内容轮换)时靠身份仍标「当前」(`saveAccount({identity})`)。
|
|
36
|
+
- **自动保险**:登录(/ctl start、codebuddy tui-start)、切换、登出前,当前登录若未被任何档覆盖(签名或身份命中)→
|
|
37
|
+
自动存 `_auto-before-{login,switch,logout}`;永不因切换/重新登录丢账号。
|
|
38
|
+
- **面板样式**:自持样式层 `client/src/agent-manager-styles.mjs`(`am-*` 类;config-button 设计语言 + 主题 var 跟随;`ensureStyles()` 幂等注入)。
|
|
39
|
+
- **账号额度(可选能力,2026-09-13)**:扩展 descriptor 加 `accountQuota: true` 并提供
|
|
40
|
+
`GET /login/quota[?force=1]`(60s 成功缓存 / 10s 失败退避;形状 `{available,display?,unit?,remaining?,total?,used?,usedPercent?,packages?,note?,expiresAt?,fetchedAt}`);
|
|
41
|
+
manager 聚合进 `/agents` 并在账号行渲染 chip(`display` 优先、tooltip 分包、点击走 `/quota/{agent}?force=1` 强制刷新)。
|
|
42
|
+
**接入契约全文(新 agent 清单 + 六家现状/待办 + 平台坑)→ `docs/pi-web-ext-agent-account-contract.md`。**
|
|
43
|
+
- **API**:`GET /accounts`(总览);`POST /accounts/{agent}/{save|switch|remove|logout} {alias?}`;
|
|
44
|
+
`/agents` 聚合带每个 agent 的 `accounts: [...]`(含 current 标记)。
|
|
45
|
+
- **边界**:只做文件级快照/恢复,不解析 token;feature/env 注入的凭据不在文件层(登出后仍生效,属预期)。
|
|
46
|
+
|
|
47
|
+
## 红线
|
|
48
|
+
- 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
|
|
49
|
+
- 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
|
|
50
|
+
- 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
|
|
51
|
+
- 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 aiti-ai contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,39 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @aiti-ai/aiti-agent-manager
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
> 统一聚合外部 agent 登录态 + 登录操作代理 + 多账号档(备份/别名/一键切换)+ 账号额度显示——不实现任何登录协议
|
|
4
|
+
|
|
5
|
+
## 来源 · Origin
|
|
6
|
+
|
|
7
|
+
本包是 **aiti** 生态的组成部分——以 [pi](https://pi.dev) 为引擎的个人 AI 开发工作台与本地调试注入生态:**aiti-agent**(自托管 Web 工作台,基于开源项目 [pi-web](https://github.com/aiti-ai/pi-web) 构建,浏览器为主操作面)+ 各开发工具内的调试注入(aiti-devtools)+ 丰富的扩展体系。
|
|
8
|
+
|
|
9
|
+
This package is part of the **aiti** ecosystem — a personal AI development workbench powered by [pi](https://pi.dev): **aiti-agent** (a self-hosted web workbench built on the open-source [pi-web](https://github.com/aiti-ai/pi-web)) plus debug injection into mainstream dev tools (aiti-devtools) and a rich extension system.
|
|
10
|
+
|
|
11
|
+
- 工作台 / workbench: <https://github.com/aiti-ai/pi-web>
|
|
12
|
+
- 生态仓库 / monorepo: <https://github.com/aiti-ai/aiti-ai>
|
|
13
|
+
- 官网 / site: <https://aiti.pw>
|
|
14
|
+
|
|
15
|
+
## 功能定位(中文)
|
|
16
|
+
|
|
17
|
+
统一聚合外部 agent 登录态 + 登录操作代理 + 多账号档(备份/别名/一键切换)+ 账号额度显示——不实现任何登录协议
|
|
18
|
+
|
|
19
|
+
多 agent 后端 · tags: `agent` · `manager` · `login` · `oauth` · `account` · `quota` · `credits` · `multi-agent`
|
|
20
|
+
|
|
21
|
+
## Positioning (English)
|
|
22
|
+
|
|
23
|
+
Unified aggregation of external agent login states + login action proxying + multi-account profiles (backup/alias/one-click switch) + account quota display — implements no login protocol at all
|
|
24
|
+
|
|
25
|
+
## 安装 / 部署
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
# 部署到 ~/.pi/web/extensions/aiti-agent-manager/ 后 reload
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## 文档 · Documentation
|
|
32
|
+
|
|
33
|
+
- 中文完整文档(aiti-docs): <https://aiti.pw/extensions/aiti-agent-manager.html>
|
|
34
|
+
- English docs: <https://aiti.pw/en/extensions/aiti-agent-manager.html>
|
|
35
|
+
- 扩展注册表 / registry: <https://aiti.pw/extensions.html> · machine-readable <https://aiti.pw/extensions.json>
|
|
36
|
+
|
|
37
|
+
## License
|
|
38
|
+
|
|
39
|
+
MIT
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* aiti-agent-manager — agents-registry 纯模块测试(descriptor 收集 / fs 探测 / 聚合 / piHost)。
|
|
3
|
+
*
|
|
4
|
+
* Run: node --test pi-web-ext/aiti-agent-manager/agents-registry.test.mjs
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import test from "node:test";
|
|
8
|
+
import { existsSync, mkdtempSync, mkdirSync, statSync, writeFileSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
AGENT_DESCRIPTOR_SUFFIX, collectAgents, aggregateAgent, probeAuthEntries,
|
|
13
|
+
readPiHostAuthProviders, toPiHostProvider,
|
|
14
|
+
} from "./agents-registry.ts";
|
|
15
|
+
|
|
16
|
+
const HOME = "/home/u";
|
|
17
|
+
|
|
18
|
+
test("collectAgents:动态收集(跳过未 provide/禁用/不合法),按 agent 名排序", () => {
|
|
19
|
+
const status = {
|
|
20
|
+
"aiti-zcode-agent": {},
|
|
21
|
+
"aiti-codebuddy-agent": {},
|
|
22
|
+
"aiti-extra": {}, // 未 provide 描述符 → 跳过
|
|
23
|
+
"aiti-disabled": { enabled: false }, // 禁用 → 跳过
|
|
24
|
+
};
|
|
25
|
+
const services = {
|
|
26
|
+
get: (k) => {
|
|
27
|
+
if (k === "aiti-zcode-agent" + AGENT_DESCRIPTOR_SUFFIX) return { agent: "zcode", label: "ZCode", modes: ["url"] };
|
|
28
|
+
if (k === "aiti-codebuddy-agent" + AGENT_DESCRIPTOR_SUFFIX) return { agent: "codebuddy", label: "CBC", modes: ["apikey", "tui"] };
|
|
29
|
+
if (k === "aiti-disabled" + AGENT_DESCRIPTOR_SUFFIX) return { agent: "x", label: "X", modes: [] };
|
|
30
|
+
if (k === "aiti-extra" + AGENT_DESCRIPTOR_SUFFIX) return { label: "缺 agent" }; // 不合法 → 跳过
|
|
31
|
+
return undefined;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
const got = collectAgents({ status, getService: services.get });
|
|
35
|
+
assert.deepEqual(got.map((c) => c.def.agent), ["codebuddy", "zcode"], "按 agent 字典序");
|
|
36
|
+
assert.equal(got[0].extId, "aiti-codebuddy-agent");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("collectAgents:status 缺失 → 空表(集群未就绪不炸)", () => {
|
|
40
|
+
assert.deepEqual(collectAgents({ status: null, getService: () => undefined }), []);
|
|
41
|
+
assert.deepEqual(collectAgents({ status: undefined, getService: () => undefined }), []);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("probeAuthEntries:file 命中(非空)/ 空文件跳过 / dir 存在即命中 / env 非空命中", () => {
|
|
45
|
+
const root = mkdtempSync(join(tmpdir(), "am-probe-"));
|
|
46
|
+
try {
|
|
47
|
+
mkdirSync(join(root, ".claude"), { recursive: true });
|
|
48
|
+
writeFileSync(join(root, ".claude", ".credentials.json"), "{}");
|
|
49
|
+
mkdirSync(join(root, ".qoder"), { recursive: true });
|
|
50
|
+
const fs = { existsSync: (p) => existsSync(p), statSync: (p) => statSync(p) };
|
|
51
|
+
assert.equal(probeAuthEntries([{ kind: "file", path: [".claude", ".credentials.json"], label: "x" }], root, {}, fs), "ready");
|
|
52
|
+
assert.equal(probeAuthEntries([{ kind: "dir", path: [".qoder"], label: "x" }], root, {}, fs), "ready");
|
|
53
|
+
assert.equal(probeAuthEntries([{ kind: "file", path: [".missing", "a.json"], label: "x" }], root, {}, fs), "missing");
|
|
54
|
+
assert.equal(probeAuthEntries([{ kind: "env", path: ["ZCODE_API_KEY"], label: "x" }], root, { ZCODE_API_KEY: "k" }, fs), "ready");
|
|
55
|
+
assert.equal(probeAuthEntries([{ kind: "env", path: ["ZCODE_API_KEY"], label: "x" }], root, { ZCODE_API_KEY: " " }, fs), "missing", "空白 env 不算");
|
|
56
|
+
assert.equal(probeAuthEntries([], root, {}, fs), "missing");
|
|
57
|
+
} finally {
|
|
58
|
+
rmSync(root, { recursive: true, force: true });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("aggregateAgent:扩展智能端点(state 归一)优先于 fs", () => {
|
|
63
|
+
const def = { agent: "codebuddy", label: "CBC", modes: ["apikey"], extReportsAuthState: true, auth: [{ kind: "file", path: [".codebuddy", "settings.json"], label: "x" }] };
|
|
64
|
+
const hit = aggregateAgent(def, { home: HOME, env: {}, fs: { existsSync: () => true, statSync: () => ({ size: 1 }) }, extStatus: { state: "ready" } });
|
|
65
|
+
assert.equal(hit.authState, "ready");
|
|
66
|
+
assert.equal(hit.source, "ext");
|
|
67
|
+
const miss = aggregateAgent(def, { home: HOME, env: {}, fs: { existsSync: () => true, statSync: () => ({ size: 1 }) }, extStatus: { state: "missing" } });
|
|
68
|
+
assert.equal(miss.authState, "missing");
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("aggregateAgent:流程态 waiting → logging-in;其余 idle", () => {
|
|
72
|
+
const def = { agent: "claude", label: "Claude", modes: ["paste"], auth: [{ kind: "file", path: [".claude", ".credentials.json"], label: "x" }] };
|
|
73
|
+
const fs = { existsSync: (p) => p.endsWith(".credentials.json"), statSync: () => ({ size: 3 }) };
|
|
74
|
+
const a = aggregateAgent(def, { home: HOME, env: {}, fs, extStatus: { state: "waiting" } });
|
|
75
|
+
assert.equal(a.flowState, "logging-in");
|
|
76
|
+
assert.equal(a.authState, "ready");
|
|
77
|
+
assert.equal(a.source, "fs");
|
|
78
|
+
const b = aggregateAgent(def, { home: HOME, env: {}, fs, extStatus: { state: "success" } });
|
|
79
|
+
assert.equal(b.flowState, "idle");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test("aggregateAgent:无候选表且无智能端点 → unknown(fail-honest)", () => {
|
|
83
|
+
const def = { agent: "qoder", label: "Qoder", modes: ["url"], auth: [] };
|
|
84
|
+
const a = aggregateAgent(def, { home: HOME, env: {}, fs: { existsSync: () => false, statSync: () => ({ size: 0 }) }, extStatus: null });
|
|
85
|
+
assert.equal(a.authState, "unknown");
|
|
86
|
+
assert.equal(a.source, "none");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("toPiHostProvider:name 优先于 email;无字段省略", () => {
|
|
90
|
+
const p = toPiHostProvider("openai", { type: "oauth", name: "李永", email: "a@b.c", expires: "2099-01-01T00:00:00.000Z" });
|
|
91
|
+
assert.equal(p.user, "李永");
|
|
92
|
+
assert.equal(p.expired, false);
|
|
93
|
+
assert.equal(toPiHostProvider("x", {}).user, undefined);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("readPiHostAuthProviders:只取展示字段(token 不出服务端)+ 坏文件容错", () => {
|
|
97
|
+
const doc = JSON.stringify({
|
|
98
|
+
zai: { type: "oauth", email: "me@x.com", access: "SECRET-A", refresh: "SECRET-R", expires: "2099-01-01T00:00:00.000Z" },
|
|
99
|
+
bad: "not-object",
|
|
100
|
+
});
|
|
101
|
+
const list = readPiHostAuthProviders("/agent", () => doc);
|
|
102
|
+
assert.equal(list.length, 1);
|
|
103
|
+
assert.equal(list[0].provider, "zai");
|
|
104
|
+
assert.equal(list[0].user, "me@x.com");
|
|
105
|
+
assert.equal(JSON.stringify(list).includes("SECRET"), false, "token 不返回");
|
|
106
|
+
assert.deepEqual(readPiHostAuthProviders("/agent", () => null), []);
|
|
107
|
+
assert.deepEqual(readPiHostAuthProviders("/agent", () => "{broken"), []);
|
|
108
|
+
});
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* agents-registry —— agent 描述符契约 + 动态收集 + 聚合逻辑(纯模块,可测)。
|
|
3
|
+
*
|
|
4
|
+
* 架构(2026-09-13 二版,拍板):**manager 不写死任何一家 agent**——每家 agent 扩展在
|
|
5
|
+
* startup 里 `ctx.services.provide("<extId>.agent", descriptor)` 自报家门(label / modes /
|
|
6
|
+
* fs 探测表 / 凭据槽位 / 登录动作);manager 遍历宿主扩展清单(`registry.status()`)
|
|
7
|
+
* 动态发现、聚合并统一展示。未 provide 的扩展 = 不出现在管理面(其登录能力本就不可用)。
|
|
8
|
+
*
|
|
9
|
+
* 聚合原则不变:
|
|
10
|
+
* - **不实现任何登录协议、不碰凭据内容**——只做 stat 级存在性探测与端点转发;
|
|
11
|
+
* - 扩展智能状态端点(`/login/status` 返回 `{state: ready|missing|…}`)优先,
|
|
12
|
+
* 否则按 descriptor.auth 候选探测表(file 非空 / dir 存在 / env 非空);
|
|
13
|
+
* - 账号档凭据槽位由 `descriptor.accountSlots(ctx)` 提供(各家的路径知识留在各家)。
|
|
14
|
+
*
|
|
15
|
+
* 探测表事实依据(2026-09-10/11 实测本机,现由各家自行声明):
|
|
16
|
+
* zcode ~/.zcode/v2/credentials.json · claude ~/.claude/.credentials.json ·
|
|
17
|
+
* codex ~/.codex/auth.json · cursor ~/.config/cursor/auth.json ·
|
|
18
|
+
* qoder ~/.qoder{,-cn}/.auth/user · codebuddy 扩展端点优先(CLI auth + 托管凭据)
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
export type AuthState = "ready" | "missing" | "unknown";
|
|
22
|
+
|
|
23
|
+
export interface AuthProbeEntry {
|
|
24
|
+
kind: "file" | "dir" | "env";
|
|
25
|
+
/** file/dir: 相对 HOME 的路径段;env: 环境变量名 */
|
|
26
|
+
path: string[];
|
|
27
|
+
label: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 登录形态:url=A url-only / paste=B paste-relay / apikey=API-key 注入 / tui=服务端 PTY 驱动。 */
|
|
31
|
+
export type AgentMode = "url" | "paste" | "apikey" | "tui";
|
|
32
|
+
|
|
33
|
+
/** 账号档凭据槽位(账号档引擎通用形状;key 为逻辑标识,path 为当前机器绝对路径)。 */
|
|
34
|
+
export interface AccountSlot {
|
|
35
|
+
key: string;
|
|
36
|
+
label: string;
|
|
37
|
+
path: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** descriptor.accountSlots 的入参:manager 组装的当前机器上下文。 */
|
|
41
|
+
export interface AccountSlotContext {
|
|
42
|
+
home: string;
|
|
43
|
+
/** PI_WEB_DATA_DIR(或默认 ~/.pi/web/data)——data 根;派生各扩展 configDir 用(<data>/<extId>/config)。 */
|
|
44
|
+
webDataRoot: string;
|
|
45
|
+
env: Record<string, string | undefined>;
|
|
46
|
+
platform: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** 登录动作代理映射(manager /ctl/{agent}/{action} 转发用)。 */
|
|
50
|
+
export interface AgentActionSpec {
|
|
51
|
+
m: string;
|
|
52
|
+
sub: string[];
|
|
53
|
+
body?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 各 agent 扩展在 startup 里 provide 的描述符(key = `<extId>.agent`)。
|
|
58
|
+
* 扩展侧直接写对象字面量(无需 import 本类型);manager 侧消费。
|
|
59
|
+
*/
|
|
60
|
+
export interface AgentDescriptor {
|
|
61
|
+
/** agent 唯一 key(/ctl/{agent} 与账号档分组用,如 "codebuddy") */
|
|
62
|
+
agent: string;
|
|
63
|
+
label: string;
|
|
64
|
+
modes: AgentMode[];
|
|
65
|
+
/** true = 本扩展 /login/status 返回 {state:"ready"|"missing"} 语义(优先于 fs 探测) */
|
|
66
|
+
extReportsAuthState?: boolean;
|
|
67
|
+
/** true = 支持官方账号额度查询(GET /login/quota;如 codebuddy credits) */
|
|
68
|
+
accountQuota?: boolean;
|
|
69
|
+
dependencyHint?: string;
|
|
70
|
+
/** fs/env 候选探测(任一命中 = ready);缺省 = 只信扩展端点 */
|
|
71
|
+
auth?: AuthProbeEntry[];
|
|
72
|
+
/** 本家账号档凭据槽位(路径知识在本家;可闭包捕获 configDir) */
|
|
73
|
+
accountSlots?: (ctx: AccountSlotContext) => AccountSlot[];
|
|
74
|
+
/** 非标准登录动作(如 codebuddy 的 tui-start/set-key/clear);缺省 = 标准 start/cancel/submit */
|
|
75
|
+
actions?: Record<string, AgentActionSpec>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 服务 key 后缀约定:`<extId>.agent`。 */
|
|
79
|
+
export const AGENT_DESCRIPTOR_SUFFIX = ".agent";
|
|
80
|
+
|
|
81
|
+
export interface CollectedAgent {
|
|
82
|
+
extId: string;
|
|
83
|
+
def: AgentDescriptor;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 从宿主扩展清单 + 服务定位器动态收集 agent 描述符(纯函数)。
|
|
88
|
+
* 跳过:未启用/报错的扩展、未 provide 描述符或不合法(agent/label 缺失)的扩展。
|
|
89
|
+
* 返回按 agent 名排序(面板顺序稳定)。
|
|
90
|
+
*/
|
|
91
|
+
export function collectAgents(opts: {
|
|
92
|
+
status: Record<string, unknown> | null;
|
|
93
|
+
getService: (key: string) => unknown;
|
|
94
|
+
}): CollectedAgent[] {
|
|
95
|
+
if (!opts.status || typeof opts.status !== "object") return [];
|
|
96
|
+
const out: CollectedAgent[] = [];
|
|
97
|
+
for (const [extId, raw] of Object.entries(opts.status)) {
|
|
98
|
+
const entry = raw as { enabled?: unknown } | undefined;
|
|
99
|
+
if (entry && entry.enabled === false) continue;
|
|
100
|
+
let d: AgentDescriptor | undefined;
|
|
101
|
+
try {
|
|
102
|
+
d = opts.getService(extId + AGENT_DESCRIPTOR_SUFFIX) as AgentDescriptor | undefined;
|
|
103
|
+
} catch {
|
|
104
|
+
d = undefined;
|
|
105
|
+
}
|
|
106
|
+
if (!d || typeof d.agent !== "string" || !d.agent) continue;
|
|
107
|
+
if (typeof d.label !== "string" || !d.label) continue;
|
|
108
|
+
if (!Array.isArray(d.modes)) d = { ...d, modes: [] };
|
|
109
|
+
out.push({ extId, def: d });
|
|
110
|
+
}
|
|
111
|
+
return out.sort((a, b) => a.def.agent.localeCompare(b.def.agent));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface ProbeFs {
|
|
115
|
+
existsSync(path: string): boolean;
|
|
116
|
+
statSync(path: string): { size: number };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** fs/env 候选探测:任一命中 = ready;全不命中 = missing(空候选表由调用方归 unknown)。 */
|
|
120
|
+
export function probeAuthEntries(entries: AuthProbeEntry[], home: string, env: Record<string, string | undefined>, fs: ProbeFs): AuthState {
|
|
121
|
+
for (const e of entries) {
|
|
122
|
+
if (e.kind === "env") {
|
|
123
|
+
const v = env[e.path[0]];
|
|
124
|
+
if (typeof v === "string" && v.trim()) return "ready";
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const p = [home, ...e.path].join("/");
|
|
128
|
+
if (!fs.existsSync(p)) continue;
|
|
129
|
+
if (e.kind === "dir") return "ready"; // 目录存在即命中
|
|
130
|
+
try {
|
|
131
|
+
if (fs.statSync(p).size > 0) return "ready"; // 文件存在且非空
|
|
132
|
+
} catch { /* raced → 下一候选 */ }
|
|
133
|
+
}
|
|
134
|
+
return "missing";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 单 agent 聚合:扩展智能端点 > fs 探测;无候选表且无智能端点 = unknown(fail-honest)。 */
|
|
138
|
+
export function aggregateAgent(def: AgentDescriptor, opts: {
|
|
139
|
+
home: string;
|
|
140
|
+
env: Record<string, string | undefined>;
|
|
141
|
+
fs: ProbeFs;
|
|
142
|
+
/** 扩展 /login/status 响应(拉取失败 = null) */
|
|
143
|
+
extStatus: { authState?: unknown; state?: unknown } | null;
|
|
144
|
+
}): { authState: AuthState; flowState: "idle" | "logging-in" | string; source: "ext" | "fs" | "none" } {
|
|
145
|
+
// 流程态:waiting = 登录中
|
|
146
|
+
const flowState = opts.extStatus?.state === "waiting" ? "logging-in" : "idle";
|
|
147
|
+
let authState: AuthState = "unknown";
|
|
148
|
+
let source: "ext" | "fs" | "none" = "none";
|
|
149
|
+
if (def.extReportsAuthState && opts.extStatus) {
|
|
150
|
+
// 扩展智能端点:authState 字段优先,state 字段(codebuddy 语义 ready|missing)归一
|
|
151
|
+
const raw = opts.extStatus.authState ?? opts.extStatus.state;
|
|
152
|
+
if (raw === "ready" || raw === "missing") {
|
|
153
|
+
authState = raw;
|
|
154
|
+
source = "ext";
|
|
155
|
+
}
|
|
156
|
+
} else if (def.auth && def.auth.length > 0) {
|
|
157
|
+
authState = probeAuthEntries(def.auth, opts.home, opts.env, opts.fs);
|
|
158
|
+
source = "fs";
|
|
159
|
+
}
|
|
160
|
+
return { authState, flowState, source };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ============================================================================
|
|
164
|
+
// §9.5 步 5:pi 宿主订阅 provider 状态(auth.json 只读;**绝不返回 token 内容**)
|
|
165
|
+
// ============================================================================
|
|
166
|
+
|
|
167
|
+
export interface PiHostProvider {
|
|
168
|
+
provider: string;
|
|
169
|
+
type?: string;
|
|
170
|
+
user?: string;
|
|
171
|
+
expires?: string;
|
|
172
|
+
/** expires 已过期(能解析时) */
|
|
173
|
+
expired?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** auth.json 单 provider 行归一(只取展示字段)。 */
|
|
177
|
+
export function toPiHostProvider(provider: string, raw: Record<string, unknown>): PiHostProvider {
|
|
178
|
+
const out: PiHostProvider = { provider };
|
|
179
|
+
if (typeof raw.type === "string") out.type = raw.type;
|
|
180
|
+
const user = typeof raw.name === "string" && raw.name ? raw.name : typeof raw.email === "string" ? raw.email : undefined;
|
|
181
|
+
if (user) out.user = user;
|
|
182
|
+
const expires = typeof raw.expires === "string" ? raw.expires : typeof raw.expires === "number" ? new Date(raw.expires).toISOString() : undefined;
|
|
183
|
+
if (expires) {
|
|
184
|
+
out.expires = expires;
|
|
185
|
+
const ms = Date.parse(expires);
|
|
186
|
+
if (Number.isFinite(ms)) out.expired = ms < Date.now();
|
|
187
|
+
}
|
|
188
|
+
return out;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* 读 pi 宿主订阅 provider 清单(auth.json 顶层键 = provider id;只取展示字段,
|
|
193
|
+
* access/refresh token 一律不读出不返回)。文件缺失/坏 JSON → 空数组(面板隐藏该组)。
|
|
194
|
+
*/
|
|
195
|
+
export function readPiHostAuthProviders(agentDir: string, readFile: (p: string) => string | null): PiHostProvider[] {
|
|
196
|
+
let raw: string | null;
|
|
197
|
+
try {
|
|
198
|
+
raw = readFile(`${agentDir}/auth.json`);
|
|
199
|
+
} catch {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
if (!raw) return [];
|
|
203
|
+
let parsed: unknown;
|
|
204
|
+
try {
|
|
205
|
+
parsed = JSON.parse(raw);
|
|
206
|
+
} catch {
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
210
|
+
return Object.entries(parsed as Record<string, unknown>)
|
|
211
|
+
.filter(([, v]) => v && typeof v === "object" && !Array.isArray(v))
|
|
212
|
+
.map(([provider, v]) => toPiHostProvider(provider, v as Record<string, unknown>));
|
|
213
|
+
}
|