Files
maestro/src/daemon/usage.ts
T
wangjia 740d2c2637 feat: PR 审核流程 + 自动合并 + macOS 通知 + 订阅额度透传 + 归档详情
PR 流程(开发→提PR→CodeReview→安全审计→审核→通过即合并):
- reviewer 拆分为 code review 与安全审计两个独立 CC run(kind=reviewer/security),
  TaskResult 四字段(summary/verdict + securitySummary/securityVerdict),闸上两节报告两枚结论徽章
- executor/merge.ts: 通过即合并——临时 worktree 内 merge --no-ff,绝不碰用户工作区/不 push;
  冲突安全拒绝(报冲突文件);重复合并幂等;合并后回收执行 worktree + 删分支
- decide 路由 merge:false 逃生口 + 看板「仅通过」按钮(目标分支被工作区检出时用)

通知(daemon/notify.ts):
- macOS 原生通知: 进审核闸(复审建议拒绝标⚠)/连续失败需人工/合并完成
- 同任务同类型 60s 抑制、osascript 转义截断、MAESTRO_NOTIFY=0 关闭

订阅额度透传(daemon/usage.ts):
- OAuth usage API(与 Claude Code/claude-hud 同源),凭证 keychain→内存零泄漏
- 60s 成败双缓存+并发去重+5s 超时,失败降级 null
- GET /api/agents 顶层 usage 字段;Agent 面板显示 5h/周用量条+重置倒计时(>80%琥珀/>95%红)

看板与生命周期:
- 归档区: 深度1整树完成沉底,时间倒序分页(尺寸 chip 10/20/50/100 置底)
- 归档详情对话框: 全属性/执行历史与时长/审批记录/状态流转时间线(GET /api/tasks/:id/events)
- 容器收口: 已拆解 Hard 子任务全 done 自动 done(afterDone 逐级向上)
- 同步按钮收进配置面板;执行白名单扩测试命令(npm/go/shellcheck/make/pytest)
- Agent 面板显示调度模式与各复杂度模型;被依赖阻塞→被阻塞

测试: 74/74(新增 merge 6/notify 10/usage 7/容器收口/双复审适配)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:22:30 +08:00

153 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { readFile } from 'node:fs/promises';
import { execFile } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
/**
* Claude 订阅额度(5 小时窗口 + 周窗口)透传。
*
* 数据源:Anthropic OAuth usage APIGET https://api.anthropic.com/api/oauth/usage
* Bearer = Claude Code 的 OAuth accessToken)。这正是 Claude Code 喂给 statusline
* `rate_limits` 字段的同一份数据(claude-hud 的 Usage/Weekly 即来源于此);daemon
* 不在 Claude Code 会话内拿不到 stdin,故直连同一端点。
*
* 凭证:优先 ~/.claude/.credentials.jsonLinux),其次 macOS keychain
* service "Claude Code-credentials")。token 只进内存,绝不写日志/响应。
*/
export interface UsageWindow {
percent: number; // 0-100 已用百分比
resetsAt: string | null; // ISO 时间,窗口重置时刻
}
export interface UsageInfo {
session: UsageWindow | null; // 5 小时滚动窗口
weekly: UsageWindow | null; // 7 天窗口
}
export interface UsageLogger {
info(msg: string): void;
error(msg: string): void;
}
export const USAGE_CACHE_MS = 60_000;
export const USAGE_ENDPOINT = 'https://api.anthropic.com/api/oauth/usage';
const FETCH_TIMEOUT_MS = 5_000;
export interface UsageDeps {
/** 取 OAuth accessToken(测试注入;返回 null = 凭证不可用)。绝不把返回值写进日志。 */
readToken?: () => Promise<string | null>;
fetchFn?: typeof fetch;
now?: () => number;
log?: UsageLogger;
}
/** 解析凭证 JSON~/.claude/.credentials.json 与 keychain 存的是同一结构) */
function tokenFromCredentialJson(raw: string, now: number, log: UsageLogger): string | null {
const j = JSON.parse(raw) as { claudeAiOauth?: { accessToken?: string; expiresAt?: number } };
const oauth = j.claudeAiOauth;
if (!oauth?.accessToken) return null;
if (typeof oauth.expiresAt === 'number' && oauth.expiresAt <= now) {
log.info('额度查询:OAuth token 已过期(等 Claude Code 刷新后自动恢复)');
return null;
}
return oauth.accessToken;
}
/** 默认凭证读取:credentials 文件 → macOS keychain。任何失败返回 null。 */
async function defaultReadToken(now: number, log: UsageLogger): Promise<string | null> {
const configDir = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
try {
const raw = await readFile(join(configDir, '.credentials.json'), 'utf8');
const tok = tokenFromCredentialJson(raw, now, log);
if (tok) return tok;
} catch {
// 文件不存在/不可解析 → 尝试 keychain
}
if (process.platform !== 'darwin') return null;
try {
const raw = await new Promise<string>((resolve, reject) => {
execFile(
'security',
['find-generic-password', '-s', 'Claude Code-credentials', '-w'],
{ timeout: 3_000 },
(err, stdout) => (err ? reject(err) : resolve(stdout)),
);
});
return tokenFromCredentialJson(raw.trim(), now, log);
} catch {
return null;
}
}
/** API 响应里的一个窗口 → UsageWindow(缺失/畸形 → null */
function parseWindow(v: unknown): UsageWindow | null {
if (!v || typeof v !== 'object') return null;
const w = v as { utilization?: unknown; resets_at?: unknown };
if (typeof w.utilization !== 'number' || !Number.isFinite(w.utilization)) return null;
return {
percent: Math.round(Math.min(100, Math.max(0, w.utilization))),
resetsAt: typeof w.resets_at === 'string' ? w.resets_at : null,
};
}
const noopLog: UsageLogger = { info: () => undefined, error: () => undefined };
/**
* 创建 getUsage:60s 内存缓存(成功与失败都缓存,杜绝重试风暴),并发去重(in-flight 共享)。
* 任何失败(无凭证 / 网络 / 非 200 / 解析失败)→ null + 记日志,绝不抛出。
*/
export function createUsageFetcher(deps: UsageDeps = {}): () => Promise<UsageInfo | null> {
const now = deps.now ?? Date.now;
const fetchFn = deps.fetchFn ?? fetch;
const log = deps.log ?? noopLog;
const readToken = deps.readToken ?? ((): Promise<string | null> => defaultReadToken(now(), log));
let cache: { at: number; value: UsageInfo | null } | null = null;
let inflight: Promise<UsageInfo | null> | null = null;
async function fetchOnce(): Promise<UsageInfo | null> {
const token = await readToken();
if (!token) {
log.info('额度查询:未取到 Claude Code OAuth 凭证,跳过');
return null;
}
const res = await fetchFn(USAGE_ENDPOINT, {
headers: {
Authorization: `Bearer ${token}`,
'anthropic-beta': 'oauth-2025-04-20',
'Content-Type': 'application/json',
},
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
log.error(`额度查询:usage API 返回 ${res.status}`);
return null;
}
const body = (await res.json()) as { five_hour?: unknown; seven_day?: unknown };
const session = parseWindow(body.five_hour);
const weekly = parseWindow(body.seven_day);
if (!session && !weekly) {
log.error('额度查询:usage API 响应缺少 five_hour/seven_day 字段');
return null;
}
return { session, weekly };
}
return async function getUsage(): Promise<UsageInfo | null> {
if (cache && now() - cache.at < USAGE_CACHE_MS) return cache.value;
if (inflight) return inflight;
inflight = fetchOnce()
.catch((e: unknown) => {
log.error(`额度查询失败:${(e as Error).message}`); // 错误信息不含 token
return null;
})
.then((value) => {
cache = { at: now(), value };
inflight = null;
return value;
});
return inflight;
};
}