@aiti-ai/aiti-claude-code-agent 0.0.0-stage → 0.1.8

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 ADDED
@@ -0,0 +1,32 @@
1
+ # aiti-claude-code-agent 扩展(部署目录直改指南)
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-claude-code-agent/`(git 管理,deploy 同步回本目录)
16
+ - 改完在源码仓跑 `node tools/deploy.mjs`(或仓库根 `node scripts/deploy-all.mjs --only aiti-claude-code-agent`)
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
+ ## 红线
25
+ - 禁轮询(setInterval 拉取);需要等待用事件/回调/SSE(桥的 SSE、aiti-event-bus topics)
26
+ - 禁重启生产服务(pi-web 3002 等 restart 须用户明确同意)
27
+ - 禁手改 dist/ 与运行时平铺文件;跨扩展通信走 ctx.services(禁 globalThis.__ext 直穿)
28
+ - 服务端:SQLite 一律 WAL + 分批小事务(批间 setImmediate 让步);禁 execFileSync 调 >1s 外部程序;>8MB 文件禁同步读
29
+
30
+ ## 账号档 / 额度对接
31
+ - 本扩展已 provide `<extId>.agent` 描述符(账号档槽位)与 `accountQuota` 额度出口 `GET /login/quota`。
32
+ 接入契约 / 现状待办 / 平台坑(.mjs reload 缓存)→ `docs/pi-web-ext-agent-account-contract.md`。
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
- # Temporary Holding Version
1
+ # @aiti-ai/aiti-claude-code-agent
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ > 驱动 Claude Code CLI 会话 —— 从 All Sessions 恢复历史行(agentKey: claude);claude 2.1.318+ keyring 凭据三级裁决登录态
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
+ 驱动 Claude Code CLI 会话 —— 从 All Sessions 恢复历史行(agentKey: claude);claude 2.1.318+ keyring 凭据三级裁决登录态
18
+
19
+ 多 agent 后端 · tags: `claude-code` · `cli` · `session` · `multi-agent`
20
+
21
+ ## Positioning (English)
22
+
23
+ Drives Claude Code CLI sessions — restores history rows from All Sessions (agentKey: claude); claude 2.1.318+ keyring credentials with a three-tier login-state adjudication
24
+
25
+ ## 安装 / 部署
26
+
27
+ ```bash
28
+ # 部署到 ~/.pi/web/extensions/aiti-claude-code-agent/ 后 reload
29
+ ```
30
+
31
+ ## 文档 · Documentation
32
+
33
+ - 中文完整文档(aiti-docs): <https://aiti.pw/extensions/aiti-claude-code-agent.html>
34
+ - English docs: <https://aiti.pw/en/extensions/aiti-claude-code-agent.html>
35
+ - 扩展注册表 / registry: <https://aiti.pw/extensions.html> · machine-readable <https://aiti.pw/extensions.json>
36
+
37
+ ## License
38
+
39
+ MIT
@@ -0,0 +1,236 @@
1
+ /**
2
+ * aiti-claude-code-agent — handle 单测(D4 权限记录 / D5 工具直播卡 / followUp 队列)。
3
+ *
4
+ * 用假 child(stdout = PassThrough,stdin.write 捕获)直驱 handle,钉死:
5
+ * 1. can_use_tool → permission_request 事件 + 🔐 pending 记录进 liveHistory(D4);
6
+ * 2. respondPermission → control_response 写回 + 决策记录(D4);
7
+ * 3. result 帧收口未决授权 → cancelled 记录 + agent_settled + 队列续轮(D4/D11);
8
+ * 4. assistant tool_use → liveHistory 原生 toolCall 块 + 原生直播条目(方案 X);
9
+ * 5. user tool_result → 原生 toolResult 条目(liveHistory + 直播,折叠配对,方案 X);
10
+ * 6. 运行中 prompt → 本地队列(queue_update),result 后自动续轮(D3/D11 探针定论)。
11
+ *
12
+ * Run: node --experimental-strip-types --test pi-web-ext/aiti-claude-code-agent/claude-handle.test.mjs
13
+ */
14
+ import assert from "node:assert/strict";
15
+ import test from "node:test";
16
+ import { PassThrough } from "node:stream";
17
+
18
+ const { ClaudeCodeHandle } = await import("./index.ts");
19
+
20
+ /** 假 child + 订阅事件捕获。 */
21
+ function makeHandle(id = "claude:new-test") {
22
+ const stdout = new PassThrough();
23
+ const stdinWrites = [];
24
+ const child = {
25
+ stdout,
26
+ stderr: { on() {} },
27
+ once() {},
28
+ stdin: { write: (s) => { stdinWrites.push(s); return true; }, writable: true },
29
+ kill() {},
30
+ exitCode: null,
31
+ };
32
+ const h = new ClaudeCodeHandle(id, child, "/tmp", undefined, "claude");
33
+ const events = [];
34
+ h.subscribe((e) => events.push(e));
35
+ const write = (frame) => stdout.write(JSON.stringify(frame) + "\n");
36
+ return { h, events, stdinWrites, write };
37
+ }
38
+
39
+ const flush = () => new Promise((r) => setTimeout(r, 2));
40
+ const customs = (events, type) => events.filter((e) => e.type === "message_end" && e.message?.customType === type);
41
+ const historyCustoms = (h, type) => h.liveHistory.filter((m) => m?.role === "custom" && m.customType === type);
42
+ const messageEnds = (events, role) => events.filter((e) => e.type === "message_end" && e.message?.role === role);
43
+ const historyRoles = (h, role) => h.liveHistory.filter((m) => m?.role === role);
44
+
45
+ test("D4: can_use_tool → permission_request + 🔐 pending 记录", () => {
46
+ const { h, events, write } = makeHandle();
47
+ write({ type: "control_request", request_id: "toolu_1", request: { subtype: "can_use_tool", tool_name: "Bash", input: { command: "ls -la" } } });
48
+ const req = events.find((e) => e.type === "permission_request");
49
+ assert.equal(req?.toolName, "Bash");
50
+ const pending = historyCustoms(h, "🔐 工具授权");
51
+ assert.equal(pending.length, 1);
52
+ assert.match(pending[0].content, /Bash/);
53
+ assert.match(pending[0].content, /ls -la/);
54
+ assert.match(pending[0].content, /⏳ 等待授权/);
55
+ assert.equal(pending[0].details.requestId, "toolu_1");
56
+ });
57
+
58
+ test("D4: respondPermission → control_response 写回 + 决策记录", () => {
59
+ const { h, events, stdinWrites, write } = makeHandle();
60
+ write({ type: "control_request", request_id: "toolu_2", request: { subtype: "can_use_tool", tool_name: "Write", input: { path: "a.txt" } } });
61
+ return h.respondPermission("toolu_2", { behavior: "deny", message: "too risky" }).then((ok) => {
62
+ assert.equal(ok, true);
63
+ const resp = stdinWrites.find((s) => s.includes("control_response"));
64
+ assert.ok(resp, "应写回 control_response");
65
+ assert.ok(resp.includes('"behavior":"deny"') || resp.includes('"behavior": "deny"'), "deny 应答");
66
+ const decided = historyCustoms(h, "🔐 工具授权");
67
+ assert.equal(decided.length, 2, "pending + decision 两条");
68
+ assert.match(decided[1].content, /⛔ 已拒绝/);
69
+ assert.match(decided[1].content, /too risky/);
70
+ });
71
+ });
72
+
73
+ test("D4: result 帧收口未决授权 → cancelled 记录 + settled + 队列续轮", async () => {
74
+ const { h, events, stdinWrites, write } = makeHandle();
75
+ write({ type: "control_request", request_id: "toolu_3", request: { subtype: "can_use_tool", tool_name: "Bash", input: {} } });
76
+ // 运行中排入一条 followUp(result 后应自动续轮)
77
+ await h.prompt("first");
78
+ const p2 = h.prompt("second", { streamingBehavior: "followUp" });
79
+ await p2;
80
+ await flush();
81
+ assert.deepEqual(events.filter((e) => e.type === "queue_update").at(-1)?.followUp, ["second"]);
82
+
83
+ write({ type: "result", subtype: "success", is_error: false, result: "done" });
84
+ await flush();
85
+ const cancelled = historyCustoms(h, "🔐 工具授权").filter((m) => m.content.includes("已取消"));
86
+ assert.equal(cancelled.length, 1, "未决授权应记取消");
87
+ assert.ok(events.some((e) => e.type === "agent_settled"), "result 应发 settled");
88
+ await flush();
89
+ const userTurn2 = stdinWrites.filter((s) => s.includes("Reply") || s.includes("second"));
90
+ assert.ok(userTurn2.some((s) => JSON.parse(s).message.content[0].text === "second"), "result 后应自动续轮发出 second");
91
+ });
92
+
93
+ test("方案 X: assistant tool_use → liveHistory 原生块 + 原生 toolCall 直播条目(无双卡)", () => {
94
+ const { h, events, write } = makeHandle();
95
+ write({ type: "assistant", message: { model: "claude-sonnet-4-5", content: [
96
+ { type: "tool_use", id: "tc_1", name: "Bash", input: { command: "pwd" } },
97
+ ] } });
98
+ const toolCallMsg = h.liveHistory.find((m) => m?.role === "assistant" && Array.isArray(m.content) && m.content.some((b) => b.type === "toolCall"));
99
+ assert.ok(toolCallMsg, "liveHistory 应有原生 toolCall 块");
100
+ const live = messageEnds(events, "assistant").filter((e) => (e.message.content ?? []).some((b) => b.type === "toolCall"));
101
+ assert.equal(live.length, 1, "原生 toolCall 直播条目恰好一条");
102
+ assert.equal(live[0].message.content[0].toolCallId, "tc_1");
103
+ assert.equal(live[0].message.content[0].toolName, "Bash");
104
+ assert.equal(live[0].message.model, "claude-sonnet-4-5");
105
+ assert.equal(events.some((e) => e.type === "message_end" && e.message?.customType === "🛠 工具调用"), false, "不再发 🛠 custom 卡");
106
+ });
107
+
108
+ test("方案 X: user tool_result → 原生 toolResult 条目(liveHistory + 直播,错误标记保真)", () => {
109
+ const { h, events, write } = makeHandle();
110
+ write({ type: "assistant", message: { content: [{ type: "tool_use", id: "tc_2", name: "Bash", input: { command: "false" } }] } });
111
+ write({ type: "user", message: { content: [{ type: "tool_result", tool_use_id: "tc_2", is_error: true, content: [{ type: "text", text: "exit 1" }] }] } });
112
+ const live = messageEnds(events, "toolResult");
113
+ assert.equal(live.length, 1, "原生 toolResult 直播条目");
114
+ assert.equal(live[0].message.toolCallId, "tc_2");
115
+ assert.equal(live[0].message.toolName, "Bash", "toolName 由 toolCallOpen 回填");
116
+ assert.equal(live[0].message.isError, true);
117
+ assert.match(live[0].message.content[0].text, /exit 1/);
118
+ const hist = historyRoles(h, "toolResult");
119
+ assert.equal(hist.length, 1, "toolResult 进 liveHistory(refetch 可折叠配对)");
120
+ assert.equal(hist[0].toolCallId, "tc_2");
121
+ assert.equal(events.some((e) => e.type === "message_end" && e.message?.customType === "❌ 工具失败"), false, "不再发 ❌ custom 卡");
122
+ });
123
+
124
+ test("followUp 能力方法 + queueManage(D2/D3 契约)", async () => {
125
+ const { h, events } = makeHandle();
126
+ await h.prompt("first");
127
+ await h.followUp("queued-a");
128
+ await h.followUp("queued-b");
129
+ await flush();
130
+ assert.deepEqual(events.filter((e) => e.type === "queue_update").at(-1)?.followUp, ["queued-a", "queued-b"]);
131
+ const list = await h.queueManage({ action: "list" });
132
+ assert.deepEqual(list.followUp, ["queued-a", "queued-b"]);
133
+ const recalled = await h.queueManage({ action: "recall", index: 0 });
134
+ assert.equal(recalled.entry.text, "queued-a");
135
+ assert.equal(h.capabilities.followUp, true);
136
+ assert.equal(h.capabilities.queueManage, true);
137
+ assert.throws(() => h.steer(), /not supported/);
138
+ });
139
+
140
+ // ---- 加固回归(docs/pi-web-ext-agent-family-hardening-plan.md 阶段 1/2)-----------
141
+
142
+ test("加固 A4: stdin.write 返回 false(背压)≠ 失败——大帧 prompt 不再误报 stdin closed", async () => {
143
+ const stdout = new PassThrough();
144
+ const child = {
145
+ stdout,
146
+ stderr: { on() {} },
147
+ once() {},
148
+ stdin: { write: () => false, writable: true }, // 恒背压(>16KB 帧的常态)
149
+ kill() {},
150
+ exitCode: null,
151
+ };
152
+ const h = new ClaudeCodeHandle("claude:new-bp", child, "/tmp", undefined, "claude");
153
+ await h.prompt("a".repeat(64 * 1024)); // 旧实现此处 throw "stdin closed" 且 running 卡死
154
+ assert.equal(h.isRunning(), true, "写成功(入缓冲)后 running 应置位");
155
+ assert.equal(h.alive(), true);
156
+ });
157
+
158
+ test("加固 A4: stdin 不可写 → prompt 抛错且 running 不置位(队列不被卡死)", async () => {
159
+ const child = {
160
+ stdout: new PassThrough(),
161
+ stderr: { on() {} },
162
+ once() {},
163
+ stdin: { writable: false, write: () => { throw new Error("EPIPE"); } },
164
+ kill() {},
165
+ exitCode: null,
166
+ };
167
+ const h = new ClaudeCodeHandle("claude:new-closed", child, "/tmp", undefined, "claude");
168
+ await assert.rejects(h.prompt("hi"), /stdin closed/);
169
+ assert.equal(h.isRunning(), false, "写失败 running 必须保持 false");
170
+ });
171
+
172
+ test("加固 A2: stdout 流 error → session_backend_dead(不再崩宿主进程)", async () => {
173
+ const { h, events } = makeHandle();
174
+ h.child.stdout.emit("error", Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" }));
175
+ await flush();
176
+ assert.equal(h.alive(), false);
177
+ assert.match(events.find((e) => e.type === "session_backend_dead")?.reason ?? "", /stdout error/);
178
+ });
179
+
180
+ test("加固 A3: 单个订阅者抛错不漏帧、不裸抛(后续订阅者照常收帧)", async () => {
181
+ const { h, write } = makeHandle();
182
+ const got = [];
183
+ h.subscribe(() => { throw new Error("listener boom"); });
184
+ h.subscribe((e) => got.push(e));
185
+ write({ type: "assistant", message: { content: [{ type: "text", text: "hi" }] } });
186
+ await flush();
187
+ assert.equal(got.length >= 1, true, "第二个订阅者应照常收到帧");
188
+ });
189
+
190
+ // ---- 上下文用量(docs/pi-web-external-agent-context-usage-plan.md)----
191
+
192
+ test("contextUsage: result 帧 usage → getContextUsage(input 侧三项相加 / 模型窗口)+ getUsageTotals 累计", async () => {
193
+ const { h, write } = makeHandle();
194
+ assert.equal(h.getContextUsage(), undefined, "首轮 result 未到 → undefined(空轨态)");
195
+
196
+ write({ type: "result", subtype: "success", is_error: false,
197
+ usage: { input_tokens: 100, output_tokens: 40, cache_read_input_tokens: 500, cache_creation_input_tokens: 60 },
198
+ total_cost_usd: 0.05, num_turns: 2 });
199
+ await flush();
200
+
201
+ const cu = h.getContextUsage();
202
+ assert.ok(cu, "result 后有数据");
203
+ assert.equal(cu.tokens, 660, "input + cacheRead + cacheWrite");
204
+ assert.equal(cu.contextWindow, 200_000, "默认 claude 窗口");
205
+ assert.ok(Math.abs(cu.percent - (660 / 200_000) * 100) < 1e-9);
206
+
207
+ const totals = h.getUsageTotals();
208
+ assert.equal(totals.inputTokens, 100);
209
+ assert.equal(totals.outputTokens, 40);
210
+ assert.equal(totals.cacheReadTokens, 500);
211
+ assert.equal(totals.cacheWriteTokens, 60);
212
+ assert.equal(totals.costUsd, 0.05);
213
+ assert.equal(totals.turns, 1);
214
+
215
+ // 第二轮:累计叠加
216
+ write({ type: "result", subtype: "success", is_error: false,
217
+ usage: { input_tokens: 30, output_tokens: 10 }, total_cost_usd: 0.02, num_turns: 1 });
218
+ await flush();
219
+ const t2 = h.getUsageTotals();
220
+ assert.equal(t2.inputTokens, 130);
221
+ assert.equal(t2.turns, 2);
222
+ const cu2 = h.getContextUsage();
223
+ assert.equal(cu2.tokens, 30, "contextUsage 只看最近一轮(当前上下文占用)");
224
+ });
225
+
226
+ test("contextUsage: [1m] 模型 → 1M 窗口;全零 usage → undefined(不报 0%)", async () => {
227
+ const { h, write } = makeHandle();
228
+ h.model = { id: "claude-sonnet-4-5[1m]", provider: "claude" };
229
+ write({ type: "result", subtype: "success", is_error: false,
230
+ usage: { input_tokens: 1_500_000, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
231
+ total_cost_usd: 1 });
232
+ await flush();
233
+ const cu = h.getContextUsage();
234
+ assert.equal(cu.contextWindow, 1_000_000);
235
+ assert.equal(cu.percent, 100, "封顶 100%");
236
+ });
@@ -0,0 +1,88 @@
1
+ /**
2
+ * claude-quota 单测(归一化 + 凭据读取 + fetch 注入)。
3
+ * 注意:本机无 claude 登录,接口形状按社区已知实现(待真机校准);本测试用假响应钉住归一化契约。
4
+ *
5
+ * Run: node --test pi-web-ext/aiti-claude-code-agent/claude-quota.test.mjs
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import test from "node:test";
9
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+
13
+ const { normalizeClaudeUsage, fetchClaudeQuota, readClaudeCreds } = await import("./claude-quota.ts");
14
+
15
+ const SAMPLE = {
16
+ five_hour: { utilization: 12.5, resets_at: "2026-09-13T15:00:00.000Z" },
17
+ seven_day: { utilization: 40, resets_at: "2026-09-15T00:00:00.000Z" },
18
+ seven_day_opus: { utilization: 5, resets_at: "2026-09-15T00:00:00.000Z" },
19
+ };
20
+
21
+ test("normalizeClaudeUsage:5h/7d 双窗口 → 剩余百分比 + 分包(含 reset 注记)", () => {
22
+ const v = normalizeClaudeUsage(SAMPLE, 1_700_000_000_000);
23
+ assert.equal(v.available, true);
24
+ assert.equal(v.unit, "%");
25
+ assert.equal(v.usedPercent, 40, "主指标取 7d");
26
+ assert.equal(v.remaining, 60);
27
+ assert.equal(v.display, "额度 5h 剩余 87.5% · 7d 剩余 60%");
28
+ assert.deepEqual(v.packages.map((p) => [p.name, p.usedPercent]), [["5 小时窗口", 12.5], ["7 天窗口", 40], ["7 天 Opus", 5]]);
29
+ assert.match(v.packages[0].note, /^重置于 /);
30
+ assert.equal(v.fetchedAt, 1_700_000_000_000);
31
+ });
32
+
33
+ test("normalizeClaudeUsage:窗口缺失优雅降级(仅 7d / 仅 5h / 全缺 = unparsed)", () => {
34
+ const only7 = normalizeClaudeUsage({ seven_day: { utilization: 90 } }, 1);
35
+ assert.equal(only7.available, true);
36
+ assert.equal(only7.display, "额度 7d 剩余 10%");
37
+ assert.equal(only7.usedPercent, 90);
38
+
39
+ const only5 = normalizeClaudeUsage({ five_hour: { utilization: 25 } }, 1);
40
+ assert.equal(only5.available, true);
41
+ assert.equal(only5.display, "额度 5h 剩余 75%");
42
+ assert.equal(only5.usedPercent, 25, "仅 5h 时主指标回落 5h");
43
+
44
+ const none = normalizeClaudeUsage({ five_hour: {} }, 1);
45
+ assert.equal(none.available, false);
46
+ assert.equal(none.reason, "unparsed");
47
+ });
48
+
49
+ test("normalizeClaudeUsage:tokenExpiresAt 透出为 expiresAt", () => {
50
+ const v = normalizeClaudeUsage(SAMPLE, 1, 1_800_000_000_000);
51
+ assert.equal(v.expiresAt, 1_800_000_000_000);
52
+ });
53
+
54
+ test("fetchClaudeQuota:成功(注入 fetch 校验 headers)+ 非 200 抛错", async () => {
55
+ const ok = await fetchClaudeQuota({
56
+ token: "tok",
57
+ expiresAt: 1_800_000_000_000,
58
+ fetchImpl: async (url, init) => {
59
+ assert.equal(String(url), "https://api.anthropic.com/api/oauth/usage");
60
+ assert.equal(init.headers.Authorization, "Bearer tok");
61
+ assert.equal(init.headers["anthropic-beta"], "oauth-2025-04-20");
62
+ return { ok: true, status: 200, json: async () => SAMPLE };
63
+ },
64
+ });
65
+ assert.equal(ok.available, true);
66
+ assert.equal(ok.expiresAt, 1_800_000_000_000);
67
+
68
+ await assert.rejects(
69
+ fetchClaudeQuota({ token: "t", fetchImpl: async () => ({ ok: false, status: 403, json: async () => ({}) }) }),
70
+ /HTTP 403/,
71
+ );
72
+ });
73
+
74
+ test("readClaudeCreds:claudeAiOauth 形态 / 顶层兼容 / 无凭据", () => {
75
+ const home = mkdtempSync(join(tmpdir(), "claude-quota-"));
76
+ try {
77
+ assert.equal(readClaudeCreds(home), null, "无文件 → null");
78
+ mkdirSync(join(home, ".claude"), { recursive: true });
79
+ writeFileSync(join(home, ".claude", ".credentials.json"), JSON.stringify({ claudeAiOauth: { accessToken: "oauth-tok", expiresAt: 1_799_000_000_000 } }));
80
+ assert.deepEqual(readClaudeCreds(home), { token: "oauth-tok", expiresAt: 1_799_000_000_000 });
81
+ writeFileSync(join(home, ".claude", ".credentials.json"), JSON.stringify({ accessToken: "flat-tok" }));
82
+ assert.deepEqual(readClaudeCreds(home), { token: "flat-tok" }, "顶层兼容");
83
+ writeFileSync(join(home, ".claude", ".credentials.json"), '{"claudeAiOauth":{}}');
84
+ assert.equal(readClaudeCreds(home), null, "无 token → null");
85
+ } finally {
86
+ rmSync(home, { recursive: true, force: true });
87
+ }
88
+ });
@@ -0,0 +1,147 @@
1
+ /**
2
+ * claude-quota —— Claude Code 账号额度(agent-manager 统一「账号额度」行显示)。
3
+ *
4
+ * 凭据:~/.claude/.credentials.json → claudeAiOauth.accessToken(兼容顶层 accessToken)。
5
+ * 接口:GET https://api.anthropic.com/api/oauth/usage(社区已知端点,
6
+ * headers: Authorization: Bearer <token> / anthropic-version: 2023-06-01 / anthropic-beta: oauth-2025-04-20)
7
+ * → 窗口字段 five_hour / seven_day / seven_day_opus / seven_day_sonnet,各 { utilization: 0-100, resets_at: ISO }。
8
+ *
9
+ * ⚠ 本机当前无 claude 登录,接口形状按社区已知实现,**待真机校准**(正常化逻辑已用假响应单测覆盖;
10
+ * 解析不到窗口时返回 available:false,不会误报)。token 仅服务端使用,绝不下发客户端。
11
+ */
12
+
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ export interface ClaudeQuotaPackage {
18
+ name?: string;
19
+ usedPercent?: number;
20
+ note?: string;
21
+ }
22
+
23
+ export type ClaudeQuotaView =
24
+ | {
25
+ available: true;
26
+ display?: string;
27
+ unit?: string;
28
+ used?: number;
29
+ usedPercent?: number;
30
+ remaining?: number;
31
+ total?: number;
32
+ packages?: ClaudeQuotaPackage[];
33
+ note?: string;
34
+ expiresAt?: number;
35
+ fetchedAt: number;
36
+ }
37
+ | { available: false; reason: string; note?: string; fetchedAt: number };
38
+
39
+ export interface ClaudeCreds {
40
+ token: string;
41
+ expiresAt?: number;
42
+ }
43
+
44
+ /** 读 Claude OAuth 凭据(无凭据/坏文件 → null)。 */
45
+ export function readClaudeCreds(home = homedir()): ClaudeCreds | null {
46
+ const p = join(home, ".claude", ".credentials.json");
47
+ if (!existsSync(p)) return null;
48
+ try {
49
+ const j = JSON.parse(readFileSync(p, "utf8")) as {
50
+ accessToken?: unknown;
51
+ expiresAt?: unknown;
52
+ claudeAiOauth?: { accessToken?: unknown; expiresAt?: unknown };
53
+ };
54
+ const oauth = j.claudeAiOauth ?? {};
55
+ const token = (typeof oauth.accessToken === "string" && oauth.accessToken)
56
+ || (typeof j.accessToken === "string" && j.accessToken)
57
+ || "";
58
+ if (!token) return null;
59
+ const rawExp = typeof oauth.expiresAt === "number" ? oauth.expiresAt : (typeof j.expiresAt === "number" ? j.expiresAt : undefined);
60
+ return { token, ...(rawExp !== undefined ? { expiresAt: rawExp } : {}) };
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ const num = (v: unknown): number | undefined => (typeof v === "number" && Number.isFinite(v) ? v : undefined);
67
+ const pctLeft = (used: number): number => Math.round(Math.max(0, Math.min(100, 100 - used)) * 10) / 10;
68
+
69
+ /** 窗口定义(显示名 + 简称)。 */
70
+ const WINDOWS: Array<{ key: string; name: string; short: string }> = [
71
+ { key: "five_hour", name: "5 小时窗口", short: "5h" },
72
+ { key: "seven_day", name: "7 天窗口", short: "7d" },
73
+ { key: "seven_day_opus", name: "7 天 Opus", short: "7d Opus" },
74
+ { key: "seven_day_sonnet", name: "7 天 Sonnet", short: "7d Sonnet" },
75
+ ];
76
+
77
+ const fmtReset = (iso: unknown): string | undefined => {
78
+ if (typeof iso !== "string" || !iso) return undefined;
79
+ const ms = Date.parse(iso);
80
+ if (!Number.isFinite(ms)) return undefined;
81
+ try { return new Date(ms).toLocaleString(); } catch { return undefined; }
82
+ };
83
+
84
+ /** oauth/usage 响应 → 归一化视图(纯函数;窗口缺失优雅降级)。 */
85
+ export function normalizeClaudeUsage(raw: unknown, now = Date.now(), tokenExpiresAt?: number): ClaudeQuotaView {
86
+ const d = (raw ?? {}) as Record<string, unknown>;
87
+ const found: Array<{ key: string; name: string; short: string; used: number; reset?: string }> = [];
88
+ for (const w of WINDOWS) {
89
+ const win = (d[w.key] ?? null) as Record<string, unknown> | null;
90
+ if (!win) continue;
91
+ const used = num(win.utilization);
92
+ if (used === undefined) continue;
93
+ found.push({ ...w, used, ...(fmtReset(win.resets_at) ? { reset: fmtReset(win.resets_at)! } : {}) });
94
+ }
95
+ if (!found.length) {
96
+ return { available: false, reason: "unparsed", note: "Claude 用量响应未识别(窗口字段缺失;待真机校准)", fetchedAt: now };
97
+ }
98
+ const primary = found.find((w) => w.key === "seven_day") ?? found[0];
99
+ const display = "额度 " + found
100
+ .filter((w) => w.key === "five_hour" || w.key === "seven_day")
101
+ .map((w) => `${w.short} 剩余 ${pctLeft(w.used)}%`)
102
+ .join(" · ");
103
+ return {
104
+ available: true,
105
+ display: display === "额度 " ? `额度 剩余 ${pctLeft(primary.used)}%` : display,
106
+ unit: "%",
107
+ used: primary.used,
108
+ usedPercent: primary.used,
109
+ remaining: pctLeft(primary.used),
110
+ total: 100,
111
+ packages: found.map((w) => ({
112
+ name: w.name,
113
+ usedPercent: w.used,
114
+ ...(w.reset ? { note: `重置于 ${w.reset}` } : {}),
115
+ })),
116
+ ...(tokenExpiresAt !== undefined ? { expiresAt: tokenExpiresAt } : {}),
117
+ fetchedAt: now,
118
+ };
119
+ }
120
+
121
+ /** 拉 Claude OAuth 用量(fetchImpl 可注入;超时/非 200/异常 → throw,调用方回落 available:false)。 */
122
+ export async function fetchClaudeQuota(opts: {
123
+ token: string;
124
+ expiresAt?: number;
125
+ timeoutMs?: number;
126
+ now?: number;
127
+ fetchImpl?: typeof fetch;
128
+ }): Promise<ClaudeQuotaView> {
129
+ const fetchFn = opts.fetchImpl ?? fetch;
130
+ const ctrl = new AbortController();
131
+ const timer = setTimeout(() => ctrl.abort(), opts.timeoutMs ?? 15_000);
132
+ try {
133
+ const res = await fetchFn("https://api.anthropic.com/api/oauth/usage", {
134
+ method: "GET",
135
+ headers: {
136
+ Authorization: `Bearer ${opts.token}`,
137
+ "anthropic-version": "2023-06-01",
138
+ "anthropic-beta": "oauth-2025-04-20",
139
+ },
140
+ signal: ctrl.signal,
141
+ });
142
+ if (!res.ok) throw new Error(`Claude 用量接口 HTTP ${res.status}`);
143
+ return normalizeClaudeUsage(await res.json(), opts.now, opts.expiresAt);
144
+ } finally {
145
+ clearTimeout(timer);
146
+ }
147
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * aiti-claude-code-agent · 受控登录 client(薄壳,paste-relay 模式)。
3
+ *
4
+ * 全部 UI/轮询/文案模板在 ./controlled-login-dialog.mjs
5
+ * (权威源 pi-web-ext/_shared/controlled-login-dialog.mjs 的部署副本,改动须双向同步)——
6
+ * 本文件只传扩展专属配置。
7
+ */
8
+ import { registerControlledLoginClient } from "./controlled-login-dialog.mjs";
9
+
10
+ export default function register(ctx) {
11
+ registerControlledLoginClient(ctx, {
12
+ extId: "aiti-claude-code-agent",
13
+ toolbarLabel: "登录 Claude Code",
14
+ title: "登录 Claude Code",
15
+ pasteHint:
16
+ "后端发起官方 claude auth login(Claude 订阅档),把授权链接交给你——" +
17
+ "在任意设备的浏览器打开并登录授权后,Claude 的完成页会显示一段一次性 code:" +
18
+ "把它(或整个回调链接)复制到下方输入框提交,后端原样喂给 login 进程即完成登录。" +
19
+ "code 一次性且时效短,请拿到后尽快提交。",
20
+ successHint: null,
21
+ });
22
+ }