pi-firecode 0.8.0 → 1.0.0

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.
Files changed (79) hide show
  1. package/README.md +35 -15
  2. package/activity.ts +92 -0
  3. package/busy.ts +188 -0
  4. package/config.example.jsonc +38 -47
  5. package/config.ts +198 -172
  6. package/deliver.ts +100 -13
  7. package/flame.ts +149 -0
  8. package/format.ts +84 -31
  9. package/header.ts +208 -82
  10. package/index.ts +17 -11
  11. package/master/actions.ts +293 -0
  12. package/master/activity-list.ts +281 -0
  13. package/master/event-card.ts +18 -37
  14. package/master/event-format.ts +71 -40
  15. package/master/guard.ts +46 -0
  16. package/master/index.ts +88 -1036
  17. package/master/list-view.ts +129 -0
  18. package/master/outbox.ts +188 -0
  19. package/master/prompts/master.zh.md +18 -13
  20. package/master/prompts/worker.zh.md +1 -1
  21. package/master/role.ts +6 -1
  22. package/master/run.ts +315 -0
  23. package/master/runtime.ts +259 -0
  24. package/master/spawn.ts +79 -38
  25. package/master/state.ts +30 -6
  26. package/master/worker-view.ts +517 -0
  27. package/package.json +1 -1
  28. package/provider/claude-sub.ts +35 -13
  29. package/provider/openai-native/src/compact-client.ts +2 -14
  30. package/provider/openai-native/src/options.ts +4 -2
  31. package/review/advisor.ts +0 -2
  32. package/review/card.ts +36 -23
  33. package/review/checkpoint.ts +2 -8
  34. package/review/evidence.ts +42 -44
  35. package/review/index.ts +271 -525
  36. package/review/occupancy.ts +25 -0
  37. package/review/outcome.ts +31 -7
  38. package/review/prompt.ts +7 -8
  39. package/review/prompts/review.en.md +7 -6
  40. package/review/prompts/review.zh.md +7 -6
  41. package/review/reviewer.ts +2 -5
  42. package/review/session.ts +11 -30
  43. package/review/state.ts +32 -60
  44. package/review/ui.ts +30 -469
  45. package/round-recorder.ts +18 -0
  46. package/session/herdr-display.ts +1 -1
  47. package/session/presets.ts +130 -87
  48. package/session/quota.ts +123 -0
  49. package/session/stats.ts +4 -1
  50. package/statusbar/index.ts +242 -104
  51. package/statusbar/render.ts +95 -163
  52. package/theme.ts +2 -41
  53. package/tools/actions.ts +53 -0
  54. package/tools/assistant-view.ts +64 -0
  55. package/tools/click-anchor.ts +56 -0
  56. package/tools/group-view.ts +420 -0
  57. package/tools/grouping.ts +146 -221
  58. package/tools/host.ts +369 -0
  59. package/tools/index.ts +108 -105
  60. package/tools/line.ts +95 -58
  61. package/tools/machine.ts +78 -0
  62. package/tools/parts.ts +2 -2
  63. package/tools/round.ts +95 -0
  64. package/tools/turn-clock.ts +41 -0
  65. package/tools/turn-summary.ts +109 -0
  66. package/truncated-write.ts +18 -0
  67. package/watcher/card.ts +28 -22
  68. package/watcher/index.ts +16 -44
  69. package/watcher/observer.ts +3 -3
  70. package/watcher/prompts/watch.zh.md +1 -1
  71. package/watcher/transcript.ts +4 -12
  72. package/flame-frames.ts +0 -460
  73. package/review/progress.ts +0 -324
  74. package/session/bark.ts +0 -157
  75. package/session/working-flame.ts +0 -116
  76. package/statusbar/quota-cache.ts +0 -60
  77. package/statusbar/quota-parse.ts +0 -85
  78. package/statusbar/quota.ts +0 -203
  79. package/statusbar/tps.ts +0 -87
@@ -1,324 +0,0 @@
1
- /**
2
- * 审查者实时进度:从结构化会话事件派生的 UI 层状态(纯函数)。
3
- * 不进 reducer、不写 checkpoint;恢复时由 reducer 状态重建骨架。
4
- */
5
- import { clip, formatModelName } from "../format.js";
6
- import type { Language } from "../config.js";
7
- import type { ReviewerStatus } from "./state.js";
8
-
9
- export interface ProgressTool {
10
- id: string;
11
- tool: string;
12
- args: string;
13
- startedAt: number;
14
- endedAt?: number;
15
- isError?: boolean;
16
- }
17
-
18
- /** 单个审查者的活动快照。 */
19
- export interface ReviewerProgress {
20
- index: number;
21
- label: string;
22
- status: ReviewerStatus;
23
- /** 当前动作的人话描述(读某文件 / 跑某命令 / 思考中)。 */
24
- action: string;
25
- /** 落定后的一行结果摘要(PASS 收敛摘要 / FAIL 首条发现);运行中为空。 */
26
- summary: string;
27
- /** 落定后的结构化多行人话结论与建议(不含命令参数等机械流水)。 */
28
- details?: string[];
29
- toolCalls: number;
30
- tokens: number;
31
- activeTools: ProgressTool[];
32
- recentTools: ProgressTool[];
33
- /** 最近动作流水,供活动测试和降级展示。 */
34
- trail: string[];
35
- /** 本模型启动时刻;活动条据此显示每个模型自己的耗时。 */
36
- startedAt: number;
37
- /** 落定时刻;落定后耗时冻结在 settledAt - startedAt。 */
38
- settledAt?: number;
39
- }
40
-
41
- const TRAIL_LIMIT = 40;
42
- const RECENT_TOOL_LIMIT = 5;
43
- const ACTION_WIDTH = 48;
44
-
45
- export function initialProgress(
46
- reviewers: readonly { model: string }[],
47
- language: Language,
48
- now = Date.now(),
49
- ): ReviewerProgress[] {
50
- return reviewers.map((reviewer, index) => ({
51
- index,
52
- label: formatModelName(reviewer.model),
53
- status: "running",
54
- action: thinkingText(language),
55
- summary: "",
56
- toolCalls: 0,
57
- tokens: 0,
58
- activeTools: [],
59
- recentTools: [],
60
- trail: [],
61
- startedAt: now,
62
- }));
63
- }
64
-
65
- /**
66
- * 把一条会话事件并入进度快照,返回新数组(无事件相关变化时返回原数组,
67
- * 调用方据此跳过重绘)。
68
- */
69
- export function applySessionEvent(
70
- progress: readonly ReviewerProgress[],
71
- index: number,
72
- event: Record<string, unknown>,
73
- language: Language,
74
- ): readonly ReviewerProgress[] {
75
- const current = progress.find((item) => item.index === index);
76
- if (!current) return progress;
77
- const next = applyReviewerEvent(current, event, language);
78
- if (next === current) return progress;
79
- return progress.map((item) => item.index === index ? next : item);
80
- }
81
-
82
- function applyReviewerEvent(
83
- item: ReviewerProgress,
84
- event: Record<string, unknown>,
85
- language: Language,
86
- ): ReviewerProgress {
87
- if (event.type === "tool_execution_start") {
88
- const tool = typeof event.toolName === "string" ? event.toolName : "tool";
89
- const args = summarizeArgs(event.args);
90
- const active: ProgressTool = {
91
- id: typeof event.toolCallId === "string" ? event.toolCallId : `${tool}:${Date.now()}`,
92
- tool,
93
- args,
94
- startedAt: Date.now(),
95
- };
96
- const action = actionOf(tool, args, language);
97
- return {
98
- ...item,
99
- action,
100
- toolCalls: item.toolCalls + 1,
101
- activeTools: [...item.activeTools, active],
102
- trail: [...item.trail, action].slice(-TRAIL_LIMIT),
103
- };
104
- }
105
- if (event.type === "tool_execution_end") {
106
- const id = typeof event.toolCallId === "string" ? event.toolCallId : "";
107
- const completed = item.activeTools.find((tool) => tool.id === id);
108
- if (!completed) return item;
109
- const activeTools = item.activeTools.filter((tool) => tool.id !== id);
110
- const current = activeTools.at(-1);
111
- return {
112
- ...item,
113
- action: current ? actionOf(current.tool, current.args, language) : thinkingText(language),
114
- activeTools,
115
- recentTools: [
116
- ...item.recentTools,
117
- { ...completed, endedAt: Date.now(), isError: event.isError === true },
118
- ].slice(-RECENT_TOOL_LIMIT),
119
- };
120
- }
121
- if (event.type === "message_end") {
122
- const message = isRecord(event.message) ? event.message : {};
123
- const usage = isRecord(message.usage) ? message.usage : {};
124
- const tokens = Number.isFinite(usage.totalTokens) ? Number(usage.totalTokens) : 0;
125
- return tokens ? { ...item, tokens: item.tokens + tokens } : item;
126
- }
127
- return item;
128
- }
129
-
130
- export function settleProgress(
131
- progress: readonly ReviewerProgress[],
132
- index: number,
133
- status: ReviewerStatus,
134
- language: Language,
135
- summary = "",
136
- rawDetails = "",
137
- ): readonly ReviewerProgress[] {
138
- const details = extractReviewDetails(status, summary, rawDetails, language);
139
- return progress.map((item) =>
140
- item.index === index
141
- ? {
142
- ...item,
143
- status,
144
- action: settledText(status, language),
145
- summary: oneLine(summary) || (details[0] ?? settledText(status, language)),
146
- details,
147
- activeTools: [],
148
- settledAt: Date.now(),
149
- }
150
- : item,
151
- );
152
- }
153
-
154
- export function extractReviewDetails(
155
- status: ReviewerStatus,
156
- summary: string,
157
- rawDetails = "",
158
- language: Language = "zh",
159
- ): string[] {
160
- const lines: string[] = [];
161
- const cleanSummary = summary.trim();
162
-
163
- if (status === "passed") {
164
- if (cleanSummary) {
165
- lines.push(cleanSummary);
166
- }
167
- if (rawDetails) {
168
- const suggestionSections = rawDetails.split(/^##\s+(?:建议(非阻塞)|Suggestions \(non-blocking\))\s*$/imu);
169
- if (suggestionSections.length > 1) {
170
- const suggestionBody = suggestionSections.slice(1).join("\n");
171
- for (const line of suggestionBody.split(/\r?\n/)) {
172
- const trimmed = line.trim();
173
- if (/^[-*+]\s+/u.test(trimmed)) {
174
- const text = trimmed.replace(/^[-*+]\s+/u, "").trim();
175
- if (text) {
176
- lines.push(language === "en" ? `Suggestion: ${text}` : `建议:${text}`);
177
- }
178
- }
179
- }
180
- }
181
- }
182
- } else if (status === "failed") {
183
- // 只列发现标题(带严重度标签),首行是数量汇总;问题正文留给结果卡。
184
- if (rawDetails) {
185
- const rawFindings = rawDetails.split(/^##\s+(?:建议(非阻塞)|Suggestions \(non-blocking\))\s*$/imu)[0] ?? "";
186
- const rawSections = rawFindings.split(/\n(?=#{1,6}\s*(?:发现|Finding))/imu);
187
- const findings: string[] = [];
188
- for (const section of rawSections) {
189
- const rawLines = section.split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
190
- if (rawLines.length === 0 || !/^#{1,6}\s*(?:发现|Finding)/iu.test(rawLines[0] ?? "")) {
191
- continue;
192
- }
193
- const heading = (rawLines[0] ?? "").replace(/^#{1,6}\s*(?:发现|Finding)\s*(?:\d+|[a-zA-Z0-9]+)?\s*[::]?\s*/iu, "").trim();
194
- let severity = "";
195
- const issueLines: string[] = [];
196
- let inIssue = false;
197
- for (let i = 1; i < rawLines.length; i += 1) {
198
- const line = rawLines[i] ?? "";
199
- const sevMatch = /^[-*+]\s*(?:\*\*)?(?:严重程度|Severity)(?:\*\*)?\s*[::]\s*(高|中|低|High|Medium|Low)/iu.exec(line);
200
- if (sevMatch) {
201
- severity = sevMatch[1] ?? "";
202
- inIssue = false;
203
- continue;
204
- }
205
- const issueStart = /^[-*+]\s*(?:\*\*)?(?:问题|Issue)(?:\*\*)?\s*[::]\s*(.*)$/u.exec(line);
206
- if (issueStart) {
207
- inIssue = true;
208
- if (issueStart[1]?.trim()) issueLines.push(issueStart[1].trim());
209
- continue;
210
- }
211
- if (/^[-*+]\s*(?:\*\*)?(?:违反|证据|验证|Violated|Evidence|Verification)/iu.test(line) || /^#{1,6}\s+/u.test(line)) {
212
- inIssue = false;
213
- continue;
214
- }
215
- if (inIssue && line) issueLines.push(line.replace(/^[-*+]\s*/u, ""));
216
- }
217
- const issueText = issueLines.join(" ").replace(/`([^`]+)`/gu, "$1").trim();
218
- const title = heading || issueText;
219
- if (!title) continue;
220
- const sevTag = severity
221
- ? `[${language === "en" ? severity : `严重·${severity}`}] `
222
- : "";
223
- findings.push(`${sevTag}${title}`);
224
- }
225
- if (findings.length > 0) {
226
- lines.push(
227
- language === "en"
228
- ? `Found ${findings.length} issue${findings.length === 1 ? "" : "s"}`
229
- : `发现 ${findings.length} 个问题`,
230
- ...findings,
231
- );
232
- }
233
- }
234
- if (lines.length === 0 && cleanSummary) {
235
- lines.push(cleanSummary);
236
- }
237
- }
238
-
239
- return lines.length > 0 ? lines : [cleanSummary || settledText(status, language)];
240
- }
241
-
242
- /** 工具调用事件 → 人话动作;非工具事件返回 undefined。 */
243
- function actionOf(tool: string, args: string, language: Language) {
244
- const verb = verbOf(tool, language);
245
- const target = tool === "bash" ? args : basename(args);
246
- return clip(target ? `${verb} ${target}` : verb, ACTION_WIDTH);
247
- }
248
-
249
- function verbOf(tool: string, language: Language) {
250
- const zh: Record<string, string> = {
251
- read: "读",
252
- bash: "跑",
253
- grep: "搜",
254
- find: "找",
255
- ls: "看",
256
- };
257
- const en: Record<string, string> = {
258
- read: "read",
259
- bash: "run",
260
- grep: "grep",
261
- find: "find",
262
- ls: "ls",
263
- };
264
- const table = language === "en" ? en : zh;
265
- return table[tool] ?? tool ?? "?";
266
- }
267
-
268
- function summarizeArgs(value: unknown) {
269
- const args = isRecord(value) ? value : {};
270
- const raw =
271
- firstString(args, ["command"]) ??
272
- firstString(args, ["path", "pattern", "query", "file"]);
273
- if (raw) return oneLine(raw);
274
- if (value === undefined) return "";
275
- try {
276
- const serialized = JSON.stringify(value);
277
- return serialized === "{}" ? "" : clip(serialized, 100);
278
- } catch {
279
- return clip(String(value), 100);
280
- }
281
- }
282
-
283
- function firstString(args: Record<string, unknown>, keys: readonly string[]) {
284
- for (const key of keys) {
285
- const value = args[key];
286
- if (typeof value === "string" && value.trim()) return value.trim();
287
- }
288
- return undefined;
289
- }
290
-
291
- function basename(path: string) {
292
- const parts = path.split("/").filter(Boolean);
293
- return parts.length > 1 ? `${parts.at(-2)}/${parts.at(-1)}` : (parts.at(-1) ?? path);
294
- }
295
-
296
- function oneLine(text: string) {
297
- return text.replace(/\s+/gu, " ").trim();
298
- }
299
-
300
- function thinkingText(language: Language) {
301
- return language === "en" ? "thinking" : "思考中";
302
- }
303
-
304
- function settledText(status: ReviewerStatus, language: Language) {
305
- if (language === "en")
306
- return status === "passed"
307
- ? "passed"
308
- : status === "failed"
309
- ? "found issues"
310
- : status === "error"
311
- ? "infra error"
312
- : "thinking";
313
- return status === "passed"
314
- ? "通过"
315
- : status === "failed"
316
- ? "发现问题"
317
- : status === "error"
318
- ? "异常"
319
- : "思考中";
320
- }
321
-
322
- function isRecord(value: unknown): value is Record<string, unknown> {
323
- return typeof value === "object" && value !== null && !Array.isArray(value);
324
- }
package/session/bark.ts DELETED
@@ -1,157 +0,0 @@
1
- /**
2
- * Bark 通知:每轮任务彻底落定(agent_settled 且不再自动续跑)时,
3
- * 把最后一条回复推送到 iPhone 的 Bark App。
4
- *
5
- * - 子代理会话不发通知,通知统一由指挥官会话发出。
6
- * - 子代理池里有待发落消息时升 timeSensitive 并带副标题,可穿透专注模式;平时为默认 active。
7
- * - 同会话固定 id:新通知经 APNs CollapseID 顶掉旧通知,通知栏每会话只留最新一条。
8
- * - 推送地址在 ~/.pi/agent/bark-key(整行即 https://api.day.app/<key>/),
9
- * 缺失时静默停用;~/.pi/agent/bark-crypto.json 存在时走 AES-256-GCM 端到端加密。
10
- */
11
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
12
- import crypto from "node:crypto";
13
- import fs from "node:fs";
14
- import os from "node:os";
15
- import path from "node:path";
16
- import { loadMasterState, masterStatePath } from "../master/state.js";
17
-
18
- const KEY_FILE = path.join(os.homedir(), ".pi", "agent", "bark-key");
19
- const CRYPTO_FILE = path.join(os.homedir(), ".pi", "agent", "bark-crypto.json");
20
- const MAX_BODY_LENGTH = 200;
21
- // pi.dev 的 logo 是白色透明背景 SVG,通知图标不支持矢量图;经 wsrv.nl 转黑底 PNG。
22
- const ICON_URL = "https://wsrv.nl/?url=pi.dev/logo.svg&w=256&h=256&output=png&bg=black";
23
-
24
- export interface BarkPayload {
25
- title: string;
26
- subtitle?: string;
27
- body: string;
28
- group: string;
29
- id: string;
30
- level: "active" | "timeSensitive";
31
- icon: string;
32
- }
33
-
34
- export function buildBarkPayload(input: {
35
- title: string;
36
- body: string;
37
- group: string;
38
- sessionId: string;
39
- awaitingDecision: boolean;
40
- }): BarkPayload {
41
- return {
42
- title: input.title,
43
- ...(input.awaitingDecision ? { subtitle: "待拍板" } : {}),
44
- body: input.body || "任务已完成",
45
- group: input.group,
46
- id: input.sessionId,
47
- level: input.awaitingDecision ? "timeSensitive" : "active",
48
- icon: ICON_URL,
49
- };
50
- }
51
-
52
- /** 只读旁路判定:状态文件损坏由 Master 自己报告与恢复,通知不放大故障,一律按无待拍板降级。 */
53
- export function hasPendingDisposition(statePath: string): boolean {
54
- try {
55
- return loadMasterState(statePath)?.workers.some((worker) => worker.disposition !== undefined) ?? false;
56
- } catch {
57
- return false;
58
- }
59
- }
60
-
61
- export function registerBark(pi: ExtensionAPI, subsession = false): void {
62
- // 通知只归指挥官会话。
63
- if (subsession) return;
64
-
65
- let lastAssistantText = "";
66
-
67
- pi.on("message_end", (event) => {
68
- if (event.message.role !== "assistant") return;
69
- const text = extractText(event.message);
70
- if (text) lastAssistantText = text;
71
- });
72
-
73
- pi.on("agent_settled", (_event, ctx) => {
74
- if (ctx.isIdle?.() !== true) return;
75
- const sessionId = ctx.sessionManager.getSessionId();
76
- const dirName = ctx.cwd ? path.basename(ctx.cwd) : "pi";
77
- void sendBark(
78
- buildBarkPayload({
79
- // 优先会话名;家目录直接跑 pi 时目录名恰好是用户名,不适合当标题。
80
- title: pi.getSessionName() || dirName,
81
- body: cleanMarkdown(lastAssistantText).slice(0, MAX_BODY_LENGTH),
82
- group: dirName,
83
- sessionId,
84
- awaitingDecision: hasPendingDisposition(masterStatePath(sessionId)),
85
- }),
86
- );
87
- });
88
- }
89
-
90
- async function sendBark(payload: BarkPayload): Promise<void> {
91
- const barkUrl = readBarkUrl();
92
- if (!barkUrl) return;
93
- const cryptoConfig = readCryptoConfig();
94
- // 加密时 id 必须提到顶层明文:折叠(CollapseID)由服务端写 APNs 头,密文内的 id 它读不到;
95
- // id 只是本地会话 uuid,无内容敏感性。level/subtitle 等内容字段由设备端解密后应用,留在密文内。
96
- const body = cryptoConfig
97
- ? { ciphertext: encryptPayload(payload, cryptoConfig.key, cryptoConfig.iv), iv: cryptoConfig.iv, id: payload.id }
98
- : payload;
99
- try {
100
- await fetch(barkUrl.endsWith("/") ? barkUrl : `${barkUrl}/`, {
101
- method: "POST",
102
- headers: { "Content-Type": "application/json; charset=utf-8" },
103
- body: JSON.stringify(body),
104
- signal: AbortSignal.timeout(5000),
105
- });
106
- } catch {
107
- // 推送是旁路能力,失败不打扰正常会话。
108
- }
109
- }
110
-
111
- function readBarkUrl(): string | undefined {
112
- try {
113
- const url = fs.readFileSync(KEY_FILE, "utf8").trim();
114
- return url.length > 0 ? url : undefined;
115
- } catch {
116
- return undefined;
117
- }
118
- }
119
-
120
- function readCryptoConfig(): { key: string; iv: string } | undefined {
121
- try {
122
- const parsed = JSON.parse(fs.readFileSync(CRYPTO_FILE, "utf8"));
123
- if (typeof parsed.key === "string" && typeof parsed.iv === "string") return parsed;
124
- } catch {
125
- // 未配置加密时走明文。
126
- }
127
- return undefined;
128
- }
129
-
130
- /** AES-256-GCM,密文+authTag 拼接后 base64,与 Bark 官方客户端解密逻辑一致。 */
131
- function encryptPayload(payload: unknown, key: string, iv: string): string {
132
- const cipher = crypto.createCipheriv("aes-256-gcm", Buffer.from(key, "utf8"), Buffer.from(iv, "utf8"));
133
- const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), "utf8"), cipher.final()]);
134
- return Buffer.concat([encrypted, cipher.getAuthTag()]).toString("base64");
135
- }
136
-
137
- function extractText(message: { content?: unknown }): string {
138
- const blocks = Array.isArray(message?.content) ? message.content : [];
139
- return blocks
140
- .filter((block: any) => block?.type === "text" && typeof block.text === "string")
141
- .map((block: any) => block.text as string)
142
- .join("\n")
143
- .trim();
144
- }
145
-
146
- /** 去掉 markdown 标记,通知栏显示纯文字。 */
147
- function cleanMarkdown(text: string): string {
148
- return text
149
- .replace(/```\w*\n?([\s\S]*?)```/g, "$1")
150
- .replace(/^#{1,6}\s+/gm, "")
151
- .replace(/^[-*+>]\s+/gm, "")
152
- .replace(/\*\*(.+?)\*\*/g, "$1")
153
- .replace(/__(.+?)__/g, "$1")
154
- .replace(/`([^`]+)`/g, "$1")
155
- .replace(/\n{2,}/g, "\n")
156
- .trim();
157
- }
@@ -1,116 +0,0 @@
1
- /**
2
- * 火焰 working 效果:工作回合期间编辑器上方居中燃烧的多行火焰 widget。
3
- *
4
- * 走 setWidget(aboveEditor):多行组件的合法槽位(审查活动框同款),由 TUI
5
- * 排版器独立渲染,与工具输出物理隔离——旧 working-style.ts 的事故是把多行帧
6
- * 塞进单行 spinner 通道,这里结构上不可能复发。agent_start 挂载、agent_end 撤下,
7
- * 动画计时器随组件 dispose 清理。高度随终端行数自适应,宽度不够时逐级缩小。
8
- *
9
- * 火焰即工作信号:回合或审查占用期隐藏宿主的 「Working...」 文本行。
10
- * review 模块也写 setWorkingVisible,且它的写入在同一同步调度链里排在占用事件之后;
11
- * 因此本模块的所有 UI 写入经微任务合并调度,在调度链收尾后作为最终仲裁者落地,
12
- * 消除「占用期 agent_end 无条件复显 Working 行」一类最后写者竞态。
13
- */
14
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
15
- import type { Component, TUI } from "@earendil-works/pi-tui";
16
- import { FLAME_FRAME_COUNT, flameFrameLines, flameFrameWidth } from "../flame-frames.js";
17
-
18
- const WIDGET_KEY = "firecode-working-flame";
19
- const FRAME_MS = 100;
20
- const MAX_HEIGHT = 10;
21
- const MIN_HEIGHT = 3;
22
-
23
- /** 高度自适应:约占终端四分之一,钳在 3–10 行;行数未知时给满高。 */
24
- export function flameHeightFor(terminalRows: number | undefined): number {
25
- if (!terminalRows || !Number.isFinite(terminalRows)) return MAX_HEIGHT;
26
- return Math.min(MAX_HEIGHT, Math.max(MIN_HEIGHT, Math.floor(terminalRows / 4)));
27
- }
28
-
29
- /** 宽度自适应:装不下就逐级降高(火焰宽随高缩),实在不行才隐藏。 */
30
- export function flameFitHeight(height: number, width: number): number {
31
- for (let fit = height; fit >= MIN_HEIGHT; fit -= 1) {
32
- if (flameFrameWidth(fit) <= width) return fit;
33
- }
34
- return 0;
35
- }
36
-
37
- export function registerWorkingFlame(pi: ExtensionAPI): void {
38
- let turnActive = false;
39
- let reviewHeld = false;
40
- let ui: ExtensionContext["ui"] | undefined;
41
- let pending = false;
42
-
43
- const apply = () => {
44
- pending = false;
45
- if (!ui) return;
46
- // Working 行:回合内由大火焰替代,审查占用期由审查活动条替代,两者都不在才复显。
47
- if (typeof ui.setWorkingVisible === "function")
48
- ui.setWorkingVisible(!turnActive && !reviewHeld);
49
- if (typeof ui.setWidget !== "function") return;
50
- if (turnActive && !reviewHeld)
51
- ui.setWidget(
52
- WIDGET_KEY,
53
- (tui: TUI) => new WorkingFlame(tui, () => tui.requestRender()),
54
- { placement: "aboveEditor" },
55
- );
56
- else ui.setWidget(WIDGET_KEY, undefined);
57
- };
58
- /** 微任务合并:同一调度链内的多个事件只落地一次,且排在 review 的同步写入之后。 */
59
- const sync = () => {
60
- if (pending) return;
61
- pending = true;
62
- queueMicrotask(apply);
63
- };
64
-
65
- pi.on("agent_start", (_event, ctx) => {
66
- if (ctx.mode !== "tui") return;
67
- ui = ctx.ui;
68
- turnActive = true;
69
- sync();
70
- });
71
- pi.on("agent_end", (_event, ctx) => {
72
- if (ctx.mode !== "tui") return;
73
- ui = ctx.ui;
74
- turnActive = false;
75
- sync();
76
- });
77
- // 审查占用频道(review 模块发布):审查活跃期活动条自带火焰,大火焰退让避免双火同烧。
78
- pi.events?.on?.("herdr:blocked", (data: { active?: boolean } | undefined) => {
79
- reviewHeld = Boolean(data?.active);
80
- sync();
81
- });
82
- }
83
-
84
- /** 居中的多行火焰;素材自带 ANSI 颜色与行尾复位。 */
85
- class WorkingFlame implements Component {
86
- private frame = 0;
87
- private readonly timer: ReturnType<typeof setInterval>;
88
-
89
- constructor(
90
- private readonly tui: TUI,
91
- requestRender: () => void,
92
- ) {
93
- this.timer = setInterval(() => {
94
- this.frame += 1;
95
- requestRender();
96
- }, FRAME_MS);
97
- this.timer.unref?.();
98
- }
99
-
100
- render(width: number): string[] {
101
- const rows = (this.tui as TUI & { terminal?: { rows?: number } }).terminal?.rows;
102
- const height = flameFitHeight(flameHeightFor(rows), Math.max(0, width));
103
- if (height === 0) return [];
104
- const flameWidth = flameFrameWidth(height);
105
- const indent = " ".repeat(Math.max(0, Math.floor((width - flameWidth) / 2)));
106
- return flameFrameLines(height, this.frame % FLAME_FRAME_COUNT).map(
107
- (line) => `${indent}${line}`,
108
- );
109
- }
110
-
111
- invalidate(): void {}
112
-
113
- dispose(): void {
114
- clearInterval(this.timer);
115
- }
116
- }
@@ -1,60 +0,0 @@
1
- /**
2
- * 配额结果的跨进程缓存:同时开多个 pi 会话时共享一次抓取,
3
- * 也共享失败退避,避免离线时每个进程各自重试。
4
- */
5
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
6
- import { join } from "node:path";
7
- import type { QuotaWindow } from "./quota-parse.js";
8
-
9
- export type QuotaCacheEntry = {
10
- /** 抓取成功的窗口;失败时为空数组 */
11
- windows: QuotaWindow[];
12
- /** 早于此刻不再发起请求(成功用 TTL,失败用退避) */
13
- nextAttemptAt: number;
14
- /** 连续失败次数,驱动退避档位 */
15
- failures: number;
16
- };
17
-
18
- export interface QuotaCache {
19
- read(provider: string): QuotaCacheEntry | undefined;
20
- write(provider: string, entry: QuotaCacheEntry): void;
21
- }
22
-
23
- function isEntry(value: unknown): value is QuotaCacheEntry {
24
- const entry = value as QuotaCacheEntry | undefined;
25
- return (
26
- !!entry &&
27
- typeof entry === "object" &&
28
- Array.isArray(entry.windows) &&
29
- typeof entry.nextAttemptAt === "number" &&
30
- typeof entry.failures === "number"
31
- );
32
- }
33
-
34
- /** 每个 provider 一个文件,避免多进程写入互相覆盖。 */
35
- export function fileQuotaCache(directory: string): QuotaCache {
36
- const pathFor = (provider: string) =>
37
- join(directory, `firecode-quota-${provider}.json`);
38
-
39
- return {
40
- read(provider) {
41
- try {
42
- const parsed: unknown = JSON.parse(readFileSync(pathFor(provider), "utf8"));
43
- return isEntry(parsed) ? parsed : undefined;
44
- } catch {
45
- return undefined;
46
- }
47
- },
48
- write(provider, entry) {
49
- const path = pathFor(provider);
50
- const temporary = `${path}.${process.pid}.tmp`;
51
- try {
52
- mkdirSync(directory, { recursive: true });
53
- writeFileSync(temporary, JSON.stringify(entry));
54
- renameSync(temporary, path);
55
- } catch {
56
- // 缓存是加速手段,写失败只意味着下次照常请求
57
- }
58
- },
59
- };
60
- }