agent-loft 0.0.0-stage → 0.2.7
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/LICENSE +21 -0
- package/README.md +110 -2
- package/bin/cli.js +52 -0
- package/package.json +64 -4
- package/src/agents/claude/account.ts +122 -0
- package/src/agents/claude/driver.ts +216 -0
- package/src/agents/claude/errors.ts +129 -0
- package/src/agents/claude/permissions.ts +260 -0
- package/src/agents/claude/probe.ts +54 -0
- package/src/agents/claude/session.ts +487 -0
- package/src/agents/codex/account.ts +122 -0
- package/src/agents/codex/app-server.ts +346 -0
- package/src/agents/codex/driver.ts +210 -0
- package/src/agents/codex/errors.ts +158 -0
- package/src/agents/codex/protocol.ts +284 -0
- package/src/agents/codex/refusals.ts +62 -0
- package/src/agents/codex/session.ts +773 -0
- package/src/channels/feishu/bot.ts +669 -0
- package/src/channels/feishu/cards.ts +360 -0
- package/src/channels/feishu/client.ts +185 -0
- package/src/channels/feishu/envelope.ts +117 -0
- package/src/channels/feishu/index.ts +62 -0
- package/src/channels/feishu/message.ts +115 -0
- package/src/channels/feishu/register.ts +171 -0
- package/src/channels/feishu/slash-sync.ts +192 -0
- package/src/channels/feishu/turn-stream.ts +98 -0
- package/src/channels/web/api.ts +645 -0
- package/src/channels/web/auth.ts +204 -0
- package/src/channels/web/http.ts +118 -0
- package/src/channels/web/push.ts +218 -0
- package/src/channels/web/server.ts +446 -0
- package/src/cli/doctor.ts +39 -0
- package/src/cli/import.ts +155 -0
- package/src/cli/pair.ts +35 -0
- package/src/cli/passwd.ts +15 -0
- package/src/cli/service.ts +137 -0
- package/src/core/agent-env.ts +11 -0
- package/src/core/command-catalog.ts +112 -0
- package/src/core/commands.ts +104 -0
- package/src/core/config.ts +285 -0
- package/src/core/dedup.ts +16 -0
- package/src/core/footers.ts +88 -0
- package/src/core/hub.ts +1152 -0
- package/src/core/interactions.ts +273 -0
- package/src/core/pairing.ts +156 -0
- package/src/core/store.ts +178 -0
- package/src/core/types.ts +213 -0
- package/src/core/uploads.ts +200 -0
- package/src/home.ts +43 -0
- package/src/main.ts +68 -0
- package/web/app.js +3731 -0
- package/web/apple-touch-icon.png +0 -0
- package/web/icon-maskable.svg +13 -0
- package/web/icon.svg +12 -0
- package/web/index.html +23 -0
- package/web/manifest.webmanifest +15 -0
- package/web/style.css +2604 -0
- package/web/sw.js +76 -0
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
// 一条会话 ↔ 一个常驻的 Claude query。
|
|
2
|
+
//
|
|
3
|
+
// 必须用「流式输入」模式:setModel / interrupt / supportedModels / applyFlagSettings
|
|
4
|
+
// 这些控制方法在 SDK 里全都只在 streaming input mode 下可用,也就是 prompt 必须是
|
|
5
|
+
// async iterable。这是架构前提,别改。
|
|
6
|
+
import fs from "node:fs";
|
|
7
|
+
import {
|
|
8
|
+
query,
|
|
9
|
+
type PermissionResult,
|
|
10
|
+
type Query,
|
|
11
|
+
type SDKMessage,
|
|
12
|
+
type SDKUserMessage,
|
|
13
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
14
|
+
import { agentEnvironment } from "../../core/agent-env.js";
|
|
15
|
+
import { describeAttachments } from "../../core/uploads.js";
|
|
16
|
+
import type {
|
|
17
|
+
AgentEvents,
|
|
18
|
+
AgentSession,
|
|
19
|
+
InteractionHost,
|
|
20
|
+
ModelInfo,
|
|
21
|
+
RuntimeInfo,
|
|
22
|
+
SavedAttachment,
|
|
23
|
+
SessionOptions,
|
|
24
|
+
UserInput,
|
|
25
|
+
} from "../../core/types.js";
|
|
26
|
+
import {
|
|
27
|
+
alwaysLabelOf,
|
|
28
|
+
describeToolCall,
|
|
29
|
+
isPreApproved,
|
|
30
|
+
isRememberable,
|
|
31
|
+
isRememberableSignature,
|
|
32
|
+
parseQuestions,
|
|
33
|
+
rememberAlways,
|
|
34
|
+
signatureOf,
|
|
35
|
+
} from "./permissions.js";
|
|
36
|
+
|
|
37
|
+
export function toModelInfo(models: Awaited<ReturnType<Query["supportedModels"]>>): ModelInfo[] {
|
|
38
|
+
return models.map((m) => ({
|
|
39
|
+
id: m.value,
|
|
40
|
+
label: m.displayName || m.value,
|
|
41
|
+
...(m.description ? { description: m.description } : {}),
|
|
42
|
+
...(m.supportedEffortLevels?.length ? { efforts: [...m.supportedEffortLevels] } : {}),
|
|
43
|
+
}));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class AsyncQueue<T> {
|
|
47
|
+
private items: T[] = [];
|
|
48
|
+
private waiting: ((result: IteratorResult<T>) => void)[] = [];
|
|
49
|
+
private closed = false;
|
|
50
|
+
|
|
51
|
+
push(item: T): void {
|
|
52
|
+
if (this.closed) return;
|
|
53
|
+
const waiter = this.waiting.shift();
|
|
54
|
+
if (waiter) waiter({ value: item, done: false });
|
|
55
|
+
else this.items.push(item);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** 丢掉还没被消费的项(停止时:排着队的消息不该在中断后接着跑) */
|
|
59
|
+
clear(): number {
|
|
60
|
+
const n = this.items.length;
|
|
61
|
+
this.items = [];
|
|
62
|
+
return n;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
close(): void {
|
|
66
|
+
this.closed = true;
|
|
67
|
+
for (const waiter of this.waiting.splice(0)) waiter({ value: undefined as never, done: true });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async *stream(): AsyncGenerator<T> {
|
|
71
|
+
while (true) {
|
|
72
|
+
const item = this.items.shift();
|
|
73
|
+
if (item !== undefined) {
|
|
74
|
+
yield item;
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
if (this.closed) return;
|
|
78
|
+
const next = await new Promise<IteratorResult<T>>((resolve) => this.waiting.push(resolve));
|
|
79
|
+
if (next.done) return;
|
|
80
|
+
yield next.value;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Claude 原生能看的内容块 —— 和 Anthropic API 的 content block 同形。 */
|
|
86
|
+
type ContentBlock =
|
|
87
|
+
| { type: "text"; text: string }
|
|
88
|
+
| { type: "image"; source: { type: "base64"; media_type: string; data: string } }
|
|
89
|
+
| { type: "document"; source: { type: "base64"; media_type: "application/pdf"; data: string } };
|
|
90
|
+
|
|
91
|
+
/** API 对单张图片的限制是 5MB(按 base64 后计),PDF 是整个请求 32MB。超了只给路径。 */
|
|
92
|
+
const INLINE_IMAGE_MAX = 3.75 * 1024 * 1024;
|
|
93
|
+
const INLINE_PDF_MAX = 20 * 1024 * 1024;
|
|
94
|
+
|
|
95
|
+
function canInline(a: SavedAttachment): boolean {
|
|
96
|
+
if (a.mediaType === "application/pdf") return a.size <= INLINE_PDF_MAX;
|
|
97
|
+
if (a.mediaType && /^image\/(png|jpeg|gif|webp)$/.test(a.mediaType)) return a.size <= INLINE_IMAGE_MAX;
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** 文字 + 附件清单,后面跟着能直接看的图片和 PDF。没有附件时就是原样的文字。 */
|
|
102
|
+
function buildContent(input: UserInput): string | ContentBlock[] {
|
|
103
|
+
if (input.attachments.length === 0) return input.text;
|
|
104
|
+
const note = describeAttachments(input.attachments, canInline);
|
|
105
|
+
const blocks: ContentBlock[] = [{ type: "text", text: input.text ? `${input.text}\n\n${note}` : note }];
|
|
106
|
+
for (const a of input.attachments) {
|
|
107
|
+
if (!canInline(a)) continue;
|
|
108
|
+
const data = fs.readFileSync(a.path).toString("base64");
|
|
109
|
+
blocks.push(
|
|
110
|
+
a.mediaType === "application/pdf"
|
|
111
|
+
? { type: "document", source: { type: "base64", media_type: "application/pdf", data } }
|
|
112
|
+
: { type: "image", source: { type: "base64", media_type: a.mediaType!, data } },
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return blocks;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export class ClaudeSession implements AgentSession {
|
|
119
|
+
private queue = new AsyncQueue<SDKUserMessage>();
|
|
120
|
+
private q: Query | null = null;
|
|
121
|
+
private pump: Promise<void> | null = null;
|
|
122
|
+
private currentId: string | undefined;
|
|
123
|
+
/**
|
|
124
|
+
* 分叉已经真正生效(CLI 报出了一个不同于原会话的新 session id)。
|
|
125
|
+
* 在那之前 CLI 起不来、半路退出,重试都还要带 forkSession,绝不能续写原会话。
|
|
126
|
+
*/
|
|
127
|
+
private forked = false;
|
|
128
|
+
/** 主线上最新一条助手消息的 uuid,见 handle() */
|
|
129
|
+
private lastAssistantUuid: string | undefined;
|
|
130
|
+
private runtime: RuntimeInfo = {};
|
|
131
|
+
private disposed = false;
|
|
132
|
+
/**
|
|
133
|
+
* 消息先留在本地,上一轮出结果了才交给 CLI。
|
|
134
|
+
*
|
|
135
|
+
* 一股脑塞给 CLI 的话,它会自己排队;而 interrupt() 只停当前这一轮,排在 CLI 里的
|
|
136
|
+
* 下一条照样会跑 —— 用户点了停止,agent 却还在干活。留在本地就能在停止时一并丢掉。
|
|
137
|
+
*/
|
|
138
|
+
private turnBusy = false;
|
|
139
|
+
private pending: UserInput[] = [];
|
|
140
|
+
/**
|
|
141
|
+
* 用户点过「本会话全部允许」。只在内存里:这个对象一换(/new、切目录、切 agent、
|
|
142
|
+
* 恢复历史、进程重启),就重新逐条询问。
|
|
143
|
+
*/
|
|
144
|
+
private allowAll = false;
|
|
145
|
+
|
|
146
|
+
constructor(
|
|
147
|
+
private readonly opts: SessionOptions,
|
|
148
|
+
private readonly events: AgentEvents,
|
|
149
|
+
private readonly host: InteractionHost,
|
|
150
|
+
) {}
|
|
151
|
+
|
|
152
|
+
get sessionId(): string | undefined {
|
|
153
|
+
return this.currentId;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private start(): void {
|
|
157
|
+
if (this.q || this.disposed) return;
|
|
158
|
+
// 重启时要接着最新的会话,而不是构造时那个 —— 否则中途崩一次,之后都被倒回去
|
|
159
|
+
const resume = this.opts.fork && !this.forked ? this.opts.resumeId : (this.currentId ?? this.opts.resumeId);
|
|
160
|
+
// fork 只在第一次生效:重启再 fork 一次就又分叉一条
|
|
161
|
+
const fork = this.opts.fork && !this.forked;
|
|
162
|
+
|
|
163
|
+
// 每个 query 一条自己的输入队列:CLI 崩溃后重新拉起时,旧 query 的输入循环可能还挂在
|
|
164
|
+
// 旧队列上等着;继续共用的话,下一条消息会被那个已经没人处理的旧循环吞掉。
|
|
165
|
+
const input = new AsyncQueue<SDKUserMessage>();
|
|
166
|
+
this.queue = input;
|
|
167
|
+
this.q = query({
|
|
168
|
+
prompt: input.stream(),
|
|
169
|
+
options: {
|
|
170
|
+
cwd: this.opts.cwd,
|
|
171
|
+
env: agentEnvironment(),
|
|
172
|
+
// 只能是 default 或 acceptEdits:bypassPermissions 会让 canUseTool 变成死代码。
|
|
173
|
+
// acceptEdits 只放行工作目录(含附件目录)内的改文件,其它照样走 canUseTool
|
|
174
|
+
permissionMode: this.opts.autoEdit ? "acceptEdits" : "default",
|
|
175
|
+
// 逐字输出给 Web 用;飞书渠道直接忽略这些片段
|
|
176
|
+
includePartialMessages: true,
|
|
177
|
+
...(this.opts.model ? { model: this.opts.model } : {}),
|
|
178
|
+
...(this.opts.effort ? { effort: this.opts.effort as never } : {}),
|
|
179
|
+
additionalDirectories: [this.opts.uploadsDir],
|
|
180
|
+
...(resume ? { resume } : {}),
|
|
181
|
+
...(fork ? { forkSession: true } : {}),
|
|
182
|
+
canUseTool: async (toolName, input) => this.canUseTool(toolName, input as Record<string, unknown>),
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
this.pump = this.consume()
|
|
187
|
+
.catch((err) => {
|
|
188
|
+
if (!this.disposed) this.events.onError(err);
|
|
189
|
+
})
|
|
190
|
+
.finally(() => {
|
|
191
|
+
// query 真的结束了(CLI 退出或输入被关)。清掉之后下一条消息会带着最新的
|
|
192
|
+
// sessionId 重新拉起来,上下文不丢;不清的话消息会进一个没人消费的队列。
|
|
193
|
+
input.close();
|
|
194
|
+
this.q = null;
|
|
195
|
+
this.pump = null;
|
|
196
|
+
// CLI 半路退出:这一轮不会再有结果了,排队的下一条会重新拉起 CLI 接着跑
|
|
197
|
+
if (this.turnBusy) this.onTurnEnd();
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private async consume(): Promise<void> {
|
|
202
|
+
if (!this.q) return;
|
|
203
|
+
for await (const message of this.q as AsyncIterable<SDKMessage>) this.handle(message);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
private handle(message: SDKMessage): void {
|
|
207
|
+
if ("session_id" in message && typeof message.session_id === "string" && message.session_id) {
|
|
208
|
+
if (message.session_id !== this.currentId) {
|
|
209
|
+
// 分叉时 CLI 报出的新 id 和原会话不同,这才算分叉成功
|
|
210
|
+
if (this.opts.fork && message.session_id !== this.opts.resumeId) this.forked = true;
|
|
211
|
+
this.currentId = message.session_id;
|
|
212
|
+
this.events.onSessionId(message.session_id);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (message.type === "system" && message.subtype === "init") {
|
|
217
|
+
this.runtime = { model: message.model, ...(message.effort ? { effort: message.effort } : {}) };
|
|
218
|
+
this.events.onTurnStart?.();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// 逐字片段。子 agent 的输出(parent_tool_use_id 非空)不算回复正文
|
|
223
|
+
if (message.type === "stream_event") {
|
|
224
|
+
if (message.parent_tool_use_id) return;
|
|
225
|
+
const event = message.event as { type?: string; delta?: { type?: string; text?: string } };
|
|
226
|
+
if (event.type === "content_block_delta" && event.delta?.type === "text_delta" && event.delta.text) {
|
|
227
|
+
this.events.onTextDelta(event.delta.text);
|
|
228
|
+
}
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (message.type === "assistant") {
|
|
233
|
+
const blocks = message.message.content;
|
|
234
|
+
if (!Array.isArray(blocks)) return;
|
|
235
|
+
const sub = !!message.parent_tool_use_id;
|
|
236
|
+
// 主线上最新的一条助手消息:这一轮结束时作为分叉位置报出去(和记录里的 uuid 一致)
|
|
237
|
+
if (!sub && message.uuid) this.lastAssistantUuid = message.uuid;
|
|
238
|
+
for (const block of blocks) {
|
|
239
|
+
if (block.type === "thinking") {
|
|
240
|
+
const thought = summarize(block.thinking);
|
|
241
|
+
if (thought) this.events.onStep("thinking", `💭 ${thought}`);
|
|
242
|
+
} else if (block.type === "tool_use") {
|
|
243
|
+
this.events.onStep("tool", `${sub ? " ↳ " : ""}🔧 ${describeToolUse(block.name, block.input)}`);
|
|
244
|
+
} else if (block.type === "text" && block.text.trim() && !sub) {
|
|
245
|
+
this.events.onText(block.text);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (message.type === "result") {
|
|
252
|
+
if (message.subtype === "success") {
|
|
253
|
+
this.events.onResult({
|
|
254
|
+
text: message.result,
|
|
255
|
+
costUsd: message.total_cost_usd,
|
|
256
|
+
durationMs: message.duration_ms,
|
|
257
|
+
...(this.lastAssistantUuid ? { anchor: this.lastAssistantUuid } : {}),
|
|
258
|
+
});
|
|
259
|
+
} else {
|
|
260
|
+
this.events.onError(new Error(`运行结束但报错:${message.subtype}`));
|
|
261
|
+
}
|
|
262
|
+
this.onTurnEnd();
|
|
263
|
+
void this.readContext();
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 一轮结束后问一次上下文占用(和 /context 同一份数据)。summary 模式只用上一次响应的
|
|
269
|
+
* usage 加本地估算,不额外调 token 计数接口,很便宜。读不到就算了,只是少个指示器。
|
|
270
|
+
*/
|
|
271
|
+
private async readContext(): Promise<void> {
|
|
272
|
+
const q = this.q;
|
|
273
|
+
if (!q || typeof q.getContextUsage !== "function") return;
|
|
274
|
+
try {
|
|
275
|
+
const r = await q.getContextUsage({ detail: "summary" });
|
|
276
|
+
const window = r.rawMaxTokens || r.maxTokens;
|
|
277
|
+
if (!window || typeof r.totalTokens !== "number") return;
|
|
278
|
+
this.events.onContext?.({
|
|
279
|
+
used: r.totalTokens,
|
|
280
|
+
window,
|
|
281
|
+
percent: typeof r.percentage === "number" ? r.percentage : Math.round((r.totalTokens / window) * 100),
|
|
282
|
+
});
|
|
283
|
+
} catch (err) {
|
|
284
|
+
console.warn("[claude] 读取上下文占用失败:", err instanceof Error ? err.message : err);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ---------- 授权 ----------
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* canUseTool:预批过的直接放行,否则交给用户。
|
|
292
|
+
* 对同一 request_id 必须幂等 —— reinitialize 之后 SDK 会重投同一个请求。
|
|
293
|
+
*/
|
|
294
|
+
private async canUseTool(toolName: string, input: Record<string, unknown>): Promise<PermissionResult> {
|
|
295
|
+
// 提问不是授权:它问的是「你选哪个」
|
|
296
|
+
if (toolName === "AskUserQuestion") {
|
|
297
|
+
const answered = await this.askQuestion(input);
|
|
298
|
+
if (answered) return answered;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// 提问、批准计划这类不是「安全确认」,而是要用户真的做决定,全部允许也照样问
|
|
302
|
+
const sessionable = isRememberable(toolName);
|
|
303
|
+
if (sessionable && this.allowAll) return { behavior: "allow", updatedInput: input };
|
|
304
|
+
|
|
305
|
+
const signature = signatureOf(toolName, input);
|
|
306
|
+
if (isPreApproved(toolName, signature)) return { behavior: "allow", updatedInput: input };
|
|
307
|
+
|
|
308
|
+
const outcome = await this.host({
|
|
309
|
+
kind: "approval",
|
|
310
|
+
title: `需要授权:${toolName}`,
|
|
311
|
+
detail: describeToolCall(toolName, input),
|
|
312
|
+
...(isRememberableSignature(toolName, signature) ? { alwaysLabel: alwaysLabelOf(toolName, signature) } : {}),
|
|
313
|
+
...(sessionable ? { allowSession: true } : {}),
|
|
314
|
+
});
|
|
315
|
+
if (outcome.settlement === "allow-always" && isRememberableSignature(toolName, signature)) rememberAlways(signature);
|
|
316
|
+
if (outcome.settlement === "allow-session") {
|
|
317
|
+
if (!this.allowAll) this.events.onStep("warning", "🔓 本会话内后续操作不再询问(/new 或切换会话后恢复逐条确认)");
|
|
318
|
+
this.allowAll = true;
|
|
319
|
+
}
|
|
320
|
+
if (outcome.settlement === "allow-once" || outcome.settlement === "allow-always" || outcome.settlement === "allow-session") {
|
|
321
|
+
return { behavior: "allow", updatedInput: input };
|
|
322
|
+
}
|
|
323
|
+
const why =
|
|
324
|
+
outcome.settlement === "timeout"
|
|
325
|
+
? "用户长时间没有响应授权请求(已超时),按拒绝处理"
|
|
326
|
+
: outcome.settlement === "cancelled"
|
|
327
|
+
? "授权请求已被取消(用户中断了这一轮或切换了会话)"
|
|
328
|
+
: "用户拒绝了此操作";
|
|
329
|
+
return { behavior: "deny", message: why };
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* AskUserQuestion:选了什么要顺着 updatedInput.answers 回填(问题原文 → 答案),
|
|
334
|
+
* 少了这一步工具会以「The user did not answer the questions.」收场。
|
|
335
|
+
*/
|
|
336
|
+
private async askQuestion(input: Record<string, unknown>): Promise<PermissionResult | null> {
|
|
337
|
+
const questions = parseQuestions(input);
|
|
338
|
+
if (!questions) return null;
|
|
339
|
+
const outcome = await this.host({ kind: "question", title: "Claude 想确认一下", questions });
|
|
340
|
+
const answers: Record<string, string> = {};
|
|
341
|
+
for (const [id, picked] of Object.entries(outcome.answers)) answers[id] = picked.join(", ");
|
|
342
|
+
if (Object.keys(answers).length > 0) {
|
|
343
|
+
return { behavior: "allow", updatedInput: { ...input, answers } };
|
|
344
|
+
}
|
|
345
|
+
// 说清楚是「没作答」而不是「拒绝这件事」,并且明说别再问一遍
|
|
346
|
+
return {
|
|
347
|
+
behavior: "deny",
|
|
348
|
+
message:
|
|
349
|
+
"用户没有作答(超时或主动跳过)。不要重复提同样的问题:按你认为合理的默认继续,并在回复里说明你替他选了什么。",
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---------- 对外 ----------
|
|
354
|
+
|
|
355
|
+
send(input: UserInput): void {
|
|
356
|
+
if (this.disposed) return;
|
|
357
|
+
if (this.turnBusy) {
|
|
358
|
+
this.pending.push(input);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
this.deliver(input);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** 一轮结束(出结果、出错或 CLI 退出):交出下一条排队的消息 */
|
|
365
|
+
private onTurnEnd(): void {
|
|
366
|
+
this.turnBusy = false;
|
|
367
|
+
const next = this.pending.shift();
|
|
368
|
+
if (next && !this.disposed) this.deliver(next);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
private deliver(input: UserInput): void {
|
|
372
|
+
// 先把内容组装出来再标记「忙」:附件读不出来(比如已被别处的 /new 清掉)时,
|
|
373
|
+
// 这条要明确报错并让会话恢复可用,而不是挂在一个永远等不到结果的「忙」上
|
|
374
|
+
let content: string | ContentBlock[];
|
|
375
|
+
try {
|
|
376
|
+
content = buildContent(input);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
const why = (err as NodeJS.ErrnoException).code === "ENOENT" ? "附件文件已不存在,请重新上传" : String(err);
|
|
379
|
+
this.events.onError(new Error(`这条消息没能发出:${why}`));
|
|
380
|
+
const next = this.pending.shift();
|
|
381
|
+
if (next && !this.disposed) this.deliver(next);
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
this.turnBusy = true;
|
|
385
|
+
this.start();
|
|
386
|
+
this.queue.push({
|
|
387
|
+
type: "user",
|
|
388
|
+
message: { role: "user", content },
|
|
389
|
+
parent_tool_use_id: null,
|
|
390
|
+
// 人敲的字必须显式标记来源;缺省会在严格的信任门上 fail closed
|
|
391
|
+
origin: { kind: "human" },
|
|
392
|
+
} as SDKUserMessage);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
async listModels(): Promise<ModelInfo[]> {
|
|
396
|
+
this.start();
|
|
397
|
+
return toModelInfo(await this.q!.supportedModels());
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async setAutoEdit(on: boolean): Promise<void> {
|
|
401
|
+
this.opts.autoEdit = on;
|
|
402
|
+
// 还没起 CLI 就不用管:下次拉起时按 opts 来
|
|
403
|
+
if (this.q) await this.q.setPermissionMode(on ? "acceptEdits" : "default");
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
async setModel(model?: string, effort?: string): Promise<void> {
|
|
407
|
+
this.start();
|
|
408
|
+
if (model !== undefined) {
|
|
409
|
+
await this.q!.setModel(model);
|
|
410
|
+
this.opts.model = model;
|
|
411
|
+
this.runtime = { ...this.runtime, model };
|
|
412
|
+
}
|
|
413
|
+
if (effort !== undefined) {
|
|
414
|
+
await this.q!.applyFlagSettings({ effortLevel: effort as never });
|
|
415
|
+
this.opts.effort = effort;
|
|
416
|
+
this.runtime = { ...this.runtime, effort };
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** 已知的模型 / 思考程度,不拉起进程、不发请求(顶栏显示用) */
|
|
421
|
+
peekRuntime(): RuntimeInfo {
|
|
422
|
+
return this.runtime;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 当前实际生效的模型与思考程度。会拉起 CLI 进程,但不消耗对话轮次。
|
|
427
|
+
* getSettings 没写进 SDK 的 Query 类型,先探测再调用。
|
|
428
|
+
*/
|
|
429
|
+
async describeRuntime(): Promise<RuntimeInfo> {
|
|
430
|
+
this.start();
|
|
431
|
+
const q = this.q as unknown as {
|
|
432
|
+
getSettings?: () => Promise<{ applied?: { model?: string; effort?: string | null } }>;
|
|
433
|
+
};
|
|
434
|
+
if (typeof q?.getSettings === "function") {
|
|
435
|
+
try {
|
|
436
|
+
const applied = (await q.getSettings()).applied;
|
|
437
|
+
if (applied?.model) {
|
|
438
|
+
this.runtime = { model: applied.model, ...(applied.effort ? { effort: applied.effort } : {}) };
|
|
439
|
+
}
|
|
440
|
+
} catch (err) {
|
|
441
|
+
console.error("[claude] 读取模型设置失败:", err);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return this.runtime;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
async interrupt(): Promise<void> {
|
|
448
|
+
// 停止 = 这一轮和排着队的都不要了
|
|
449
|
+
const dropped = this.pending.length + this.queue.clear();
|
|
450
|
+
this.pending = [];
|
|
451
|
+
if (dropped > 0) this.events.onDropped?.(dropped);
|
|
452
|
+
if (!this.q) return;
|
|
453
|
+
try {
|
|
454
|
+
await this.q.interrupt();
|
|
455
|
+
} catch (err) {
|
|
456
|
+
console.warn("[claude] 中断失败:", err instanceof Error ? err.message : err);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async dispose(): Promise<void> {
|
|
461
|
+
this.disposed = true;
|
|
462
|
+
this.pending = [];
|
|
463
|
+
this.queue.close();
|
|
464
|
+
try {
|
|
465
|
+
await this.q?.interrupt();
|
|
466
|
+
} catch {
|
|
467
|
+
// 已经停了就无所谓
|
|
468
|
+
}
|
|
469
|
+
await this.pump?.catch(() => {});
|
|
470
|
+
this.q = null;
|
|
471
|
+
this.pump = null;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
export function summarize(text: string, max = 80): string {
|
|
476
|
+
const line = text.replace(/\s+/g, " ").trim();
|
|
477
|
+
return line.length > max ? `${line.slice(0, max)}…` : line;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function describeToolUse(name: string, input: unknown): string {
|
|
481
|
+
const record = (input ?? {}) as Record<string, unknown>;
|
|
482
|
+
if (name === "Bash" && typeof record.command === "string") return `Bash: ${summarize(record.command, 60)}`;
|
|
483
|
+
if (typeof record.file_path === "string") return `${name}: ${record.file_path}`;
|
|
484
|
+
if (typeof record.pattern === "string") return `${name}: ${summarize(record.pattern, 40)}`;
|
|
485
|
+
if (typeof record.description === "string") return `${name}: ${summarize(record.description, 50)}`;
|
|
486
|
+
return name;
|
|
487
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// 账号额度:Codex 的 5 小时 / 7 天用量窗口。
|
|
2
|
+
//
|
|
3
|
+
// 这块是补一个认知落差:Codex 自己的 TUI 里 `/status` 会显示剩余额度,
|
|
4
|
+
// 而agent-loft的 `/status` 是另一条命令(同名而已),本来只报会话和目录。
|
|
5
|
+
// 用的人不会关心这个区别,他只知道「同样敲 /status,这边少了额度」。
|
|
6
|
+
// 与其解释,不如把它显示出来 —— app-server 本来就给了。
|
|
7
|
+
import type { QuotaInfo } from "../../core/types.js";
|
|
8
|
+
import { appServer } from "./app-server.js";
|
|
9
|
+
|
|
10
|
+
type UsageWindow = {
|
|
11
|
+
usedPercent: number;
|
|
12
|
+
/** 窗口长度(分钟)。300 = 5 小时,10080 = 7 天 */
|
|
13
|
+
windowDurationMins: number;
|
|
14
|
+
/** 重置时刻,Unix 秒 */
|
|
15
|
+
resetsAt: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
type RateLimits = {
|
|
19
|
+
planType?: string | null;
|
|
20
|
+
primary?: UsageWindow | null;
|
|
21
|
+
secondary?: UsageWindow | null;
|
|
22
|
+
credits?: { hasCredits?: boolean; unlimited?: boolean; balance?: string } | null;
|
|
23
|
+
/** 非空表示已经撞上限了 */
|
|
24
|
+
rateLimitReachedType?: string | null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 额度是会变的,但 /status 也可能被连敲几次,缓存一小会儿。
|
|
29
|
+
* 60 秒足够挡住手抖,又不至于让人看到明显过期的数字。
|
|
30
|
+
*/
|
|
31
|
+
const CACHE_MS = 60_000;
|
|
32
|
+
let cache: { at: number; value: RateLimits | null } | null = null;
|
|
33
|
+
|
|
34
|
+
export async function readRateLimits(): Promise<RateLimits | null> {
|
|
35
|
+
if (cache && Date.now() - cache.at < CACHE_MS) return cache.value;
|
|
36
|
+
try {
|
|
37
|
+
const res = await appServer.call<{ rateLimits?: RateLimits }>(
|
|
38
|
+
"account/rateLimits/read",
|
|
39
|
+
{},
|
|
40
|
+
);
|
|
41
|
+
cache = { at: Date.now(), value: res.rateLimits ?? null };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// 读不到额度不该让整条 /status 失败 —— 它只是其中一行
|
|
44
|
+
console.error("[额度] 读取失败:", err instanceof Error ? err.message : err);
|
|
45
|
+
cache = { at: Date.now(), value: null };
|
|
46
|
+
}
|
|
47
|
+
return cache.value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 300 → 「5h」,10080 → 「7d」 */
|
|
51
|
+
function windowShort(mins: number): string {
|
|
52
|
+
if (mins % (60 * 24) === 0) return `${mins / (60 * 24)}d`;
|
|
53
|
+
if (mins % 60 === 0) return `${mins / 60}h`;
|
|
54
|
+
return `${mins}m`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 侧栏用的结构化额度 */
|
|
58
|
+
export function toQuota(limits: RateLimits | null): QuotaInfo | null {
|
|
59
|
+
if (!limits) return null;
|
|
60
|
+
const windows = [limits.primary, limits.secondary]
|
|
61
|
+
.filter((w): w is UsageWindow => !!w)
|
|
62
|
+
.map((w) => ({
|
|
63
|
+
label: windowName(w.windowDurationMins),
|
|
64
|
+
short: windowShort(w.windowDurationMins),
|
|
65
|
+
percent: w.usedPercent,
|
|
66
|
+
resetsAt: w.resetsAt * 1000,
|
|
67
|
+
}));
|
|
68
|
+
if (windows.length === 0) return null;
|
|
69
|
+
const credits = limits.credits;
|
|
70
|
+
const extra = credits?.unlimited ? "额度包:无限" : credits?.hasCredits && credits.balance ? `额度包:余 ${credits.balance}` : undefined;
|
|
71
|
+
return {
|
|
72
|
+
...(limits.planType ? { plan: limits.planType } : {}),
|
|
73
|
+
windows,
|
|
74
|
+
...(extra ? { extra } : {}),
|
|
75
|
+
...(limits.rateLimitReachedType ? { reached: true } : {}),
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** 300 → 「5 小时」,10080 → 「7 天」,45 → 「45 分钟」 */
|
|
80
|
+
function windowName(mins: number): string {
|
|
81
|
+
if (mins % (60 * 24) === 0) return `${mins / (60 * 24)} 天`;
|
|
82
|
+
if (mins % 60 === 0) return `${mins / 60} 小时`;
|
|
83
|
+
return `${mins} 分钟`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** 距离重置还有多久。已经过了就说「即将重置」,不显示负数。 */
|
|
87
|
+
function untilReset(resetsAt: number, now = Date.now()): string {
|
|
88
|
+
const seconds = resetsAt - Math.floor(now / 1000);
|
|
89
|
+
if (seconds <= 0) return "即将重置";
|
|
90
|
+
const days = Math.floor(seconds / 86400);
|
|
91
|
+
const hours = Math.floor((seconds % 86400) / 3600);
|
|
92
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
93
|
+
if (days > 0) return `${days} 天 ${hours} 小时后重置`;
|
|
94
|
+
if (hours > 0) return `${hours} 小时 ${minutes} 分后重置`;
|
|
95
|
+
return `${minutes} 分钟后重置`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function line(label: string, w: UsageWindow): string {
|
|
99
|
+
return ` ${label} 已用 ${w.usedPercent}% ${untilReset(w.resetsAt)}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 渲染成 /status 里的几行。没有可显示的就返回空数组,让调用方自然略过
|
|
104
|
+
* —— 与其印一行「额度:未知」,不如什么都不印。
|
|
105
|
+
*/
|
|
106
|
+
export function describeRateLimits(limits: RateLimits | null): string[] {
|
|
107
|
+
if (!limits) return [];
|
|
108
|
+
const rows: string[] = [];
|
|
109
|
+
if (limits.primary) rows.push(line(windowName(limits.primary.windowDurationMins), limits.primary));
|
|
110
|
+
if (limits.secondary) {
|
|
111
|
+
rows.push(line(windowName(limits.secondary.windowDurationMins), limits.secondary));
|
|
112
|
+
}
|
|
113
|
+
if (rows.length === 0) return [];
|
|
114
|
+
|
|
115
|
+
const credits = limits.credits;
|
|
116
|
+
if (credits?.unlimited) rows.push(" 额度包 无限");
|
|
117
|
+
else if (credits?.hasCredits && credits.balance) rows.push(` 额度包 余 ${credits.balance}`);
|
|
118
|
+
|
|
119
|
+
if (limits.rateLimitReachedType) rows.push(" ⚠️ 已达上限,等窗口重置后才能继续");
|
|
120
|
+
|
|
121
|
+
return [`额度${limits.planType ? `(${limits.planType})` : ""}`, ...rows];
|
|
122
|
+
}
|