@ia-ccun/code-agent-cli 0.0.52-beta.1 → 0.0.52-beta.3

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.
@@ -146,6 +146,19 @@ function setDisabledForModels(models: Array<string | { model: string; minEffort?
146
146
  }
147
147
 
148
148
  // ============== 工具清单序列化 ==============
149
+ /**
150
+ * stableStringify - 稳定序列化对象(键排序)
151
+ *
152
+ * 用于序列化工具参数,确保参数对象的键顺序一致,便于对比和展示。
153
+ *
154
+ * 功能说明:
155
+ * - 处理 null 和非对象类型:直接 JSON.stringify
156
+ * - 处理数组:递归序列化每个元素,undefined 替换为 "null"
157
+ * - 处理对象:键排序后序列化,跳过 undefined 值
158
+ *
159
+ * @param value - 要序列化的值
160
+ * @returns 稳定的 JSON 字符串(键排序)
161
+ */
149
162
  function stableStringify(value: unknown): string {
150
163
  if (value === null || typeof value !== "object") {
151
164
  return JSON.stringify(value);
@@ -40,6 +40,18 @@ function matchesBtwFocusShortcut(data: string): boolean {
40
40
  return BTW_FOCUS_SHORTCUTS.some((shortcut) => matchesKey(data, shortcut));
41
41
  }
42
42
 
43
+ /**
44
+ * 类型守卫:检查 entry 是否是有效的 Message 对象
45
+ * ✅ 用于安全转换,避免使用危险的 as unknown as
46
+ */
47
+ function isMessageLike(entry: unknown): entry is Partial<Message> & { role?: string; customType?: string; content?: unknown } {
48
+ if (!entry || typeof entry !== "object") {
49
+ return false;
50
+ }
51
+ const candidate = entry as { role?: unknown; customType?: string; content?: unknown };
52
+ return typeof candidate.role === "string" && Array.isArray(candidate.content);
53
+ }
54
+
43
55
  const BTW_SYSTEM_PROMPT = [
44
56
  "You are having an aside conversation with the user, separate from their main working session.",
45
57
  "If main session messages are provided, they are for context only — that work is being handled by another agent.",
@@ -277,17 +289,17 @@ function buildBtwSeedState(
277
289
  (message) => !isVisibleBtwMessage(message),
278
290
  ),
279
291
  );
280
- } catch {
292
+ } catch (error) {
293
+ // ✅ 改进错误处理:记录错误而不忽略
294
+ console.warn('[btw] buildSessionContext failed, using fallback:', error);
281
295
  messages.push(
282
296
  ...ctx.sessionManager.getEntries().flatMap((entry) => {
283
- if (!entry || typeof entry !== "object") {
297
+ // ✅ 使用类型守卫进行安全转换,避免 as unknown as
298
+ if (!isMessageLike(entry)) {
284
299
  return [];
285
300
  }
286
301
 
287
- const message = entry as unknown as Partial<Message> & { role?: string; customType?: string; content?: unknown };
288
- if (typeof message.role !== "string" || !Array.isArray(message.content)) {
289
- return [];
290
- }
302
+ const message = entry; // ✅ 类型安全
291
303
 
292
304
  return isVisibleBtwMessage({ role: message.role, customType: message.customType }) ? [] : [message as Message];
293
305
  }),
@@ -380,7 +392,9 @@ function formatToolPreview(value: unknown): string {
380
392
  return "";
381
393
  }
382
394
  return preview.length > 120 ? `${preview.slice(0, 117)}...` : preview;
383
- } catch {
395
+ } catch (error) {
396
+ // ✅ 改进错误处理:记录错误而不忽略
397
+ console.warn('[btw] JSON.stringify failed in formatToolPreview:', error);
384
398
  return "";
385
399
  }
386
400
  }
@@ -396,6 +410,26 @@ function createEmptyTranscriptState(): BtwTranscriptState {
396
410
  };
397
411
  }
398
412
 
413
+ /**
414
+ * clearTranscriptState - 清理 transcriptState 的所有资源
415
+ * ✅ 清理 entries 数组
416
+ * ✅ 清理 toolCalls Map(防止内存泄漏)
417
+ * ✅ 重置所有状态
418
+ */
419
+ function clearTranscriptState(state: BtwTranscriptState): void {
420
+ // ✅ 清理 toolCalls Map(防止内存泄漏)
421
+ state.toolCalls.clear();
422
+
423
+ // 清理 entries 数组
424
+ state.entries = [];
425
+
426
+ // 重置状态
427
+ state.nextEntryId = 1;
428
+ state.nextTurnId = 1;
429
+ state.currentTurnId = null;
430
+ state.lastTurnId = null;
431
+ }
432
+
399
433
  function appendTranscriptEntry<T extends BtwTranscriptEntry>(
400
434
  state: BtwTranscriptState,
401
435
  entry: Omit<T, "id">,
@@ -444,6 +478,13 @@ function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null)
444
478
  if (state.currentTurnId === resolvedTurnId) {
445
479
  state.currentTurnId = null;
446
480
  }
481
+
482
+ // ✅ 清理当前 turn 的 toolCalls(防止内存泄漏)
483
+ for (const [toolCallId, toolCall] of state.toolCalls.entries()) {
484
+ if (toolCall.turnId === resolvedTurnId) {
485
+ state.toolCalls.delete(toolCallId);
486
+ }
487
+ }
447
488
  }
448
489
 
449
490
  function removeTranscriptTurn(state: BtwTranscriptState, turnId: number | null): void {
@@ -574,7 +615,9 @@ function summarizeToolResult(value: unknown, maxLength = 400): { content: string
574
615
  } else if (value !== undefined) {
575
616
  try {
576
617
  content = JSON.stringify(value, null, 2);
577
- } catch {
618
+ } catch (error) {
619
+ // ✅ 改进错误处理:记录错误而不忽略
620
+ console.warn('[btw] JSON.stringify failed in summarizeToolResult:', error);
578
621
  content = String(value);
579
622
  }
580
623
  }
@@ -1340,8 +1383,9 @@ export default function (pi: ExtensionAPI) {
1340
1383
 
1341
1384
  try {
1342
1385
  unsubscribe();
1343
- } catch {
1344
- // Ignore unsubscribe errors during BTW session replacement/shutdown.
1386
+ } catch (error) {
1387
+ // ✅ 改进错误处理:记录错误而不忽略
1388
+ console.warn('[btw] Unsubscribe failed during session removal:', error);
1345
1389
  }
1346
1390
  }
1347
1391
 
@@ -1402,6 +1446,12 @@ export default function (pi: ExtensionAPI) {
1402
1446
  async function disposeBtwSession(): Promise<void> {
1403
1447
  const current = activeBtwSession;
1404
1448
  activeBtwSession = null;
1449
+
1450
+ // ✅ 清理 transcriptState(防止内存泄漏)
1451
+ if (transcriptState) {
1452
+ clearTranscriptState(transcriptState);
1453
+ }
1454
+
1405
1455
  if (!current) {
1406
1456
  return;
1407
1457
  }
@@ -1410,8 +1460,9 @@ export default function (pi: ExtensionAPI) {
1410
1460
 
1411
1461
  try {
1412
1462
  await current.session.abort();
1413
- } catch {
1414
- // Ignore abort errors during BTW session replacement/shutdown.
1463
+ } catch (error) {
1464
+ // ✅ 改进错误处理:记录错误而不忽略
1465
+ console.warn('[btw] Session abort failed during disposal:', error);
1415
1466
  }
1416
1467
 
1417
1468
  current.session.dispose();
@@ -2144,8 +2195,9 @@ export default function (pi: ExtensionAPI) {
2144
2195
  } finally {
2145
2196
  try {
2146
2197
  await session.abort();
2147
- } catch {
2148
- // Ignore abort errors during summarize session shutdown.
2198
+ } catch (error) {
2199
+ // ✅ 改进错误处理:记录错误而不忽略
2200
+ console.warn('[btw] Session abort failed during summarize shutdown:', error);
2149
2201
  }
2150
2202
  session.dispose();
2151
2203
  }
@@ -0,0 +1,362 @@
1
+ /**
2
+ * Random Pet Widget Extension
3
+ *
4
+ * Displays a random animal sprite below the input editor.
5
+ * Each session randomly selects from 18 different animals.
6
+ * Inspired by claude-petpet's sprite system.
7
+ */
8
+
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+
11
+ // 帽子定义(参考 claude-petpet)
12
+ const HATS: Record<string, string> = {
13
+ none: "",
14
+ crown: " \\^^^/ ",
15
+ tophat: " [___] ",
16
+ propeller: " -+- ",
17
+ halo: " ( ) ",
18
+ wizard: " /^\\ ",
19
+ beanie: " (___) ",
20
+ tinyduck: " ,> ",
21
+ };
22
+
23
+ // 18 种动物精灵定义(参考 claude-petpet)
24
+ interface PetDefinition {
25
+ name: string;
26
+ emoji: string;
27
+ frames: string[][];
28
+ speed: number;
29
+ }
30
+
31
+ const PETS: Record<string, PetDefinition> = {
32
+ cat: {
33
+ name: "小猫",
34
+ emoji: "🐱",
35
+ frames: [
36
+ [" /\\_/\\ ", " ( o o) ", " ( ω ) ", ' (")_(") '],
37
+ [" /\\_/\\ ", " ( ^ ^) ", " ( ω ) ", ' (")_(") '],
38
+ [" /\\_/\\ ", " ( - -) ", " ( ω ) ", ' (")_(") '],
39
+ ],
40
+ speed: 500,
41
+ },
42
+ rabbit: {
43
+ name: "兔子",
44
+ emoji: "🐰",
45
+ frames: [
46
+ [" (\\__/) ", " ( o o ) ", " =( .. )= ", ' (")_(") '],
47
+ [" (\\__/) ", " ( ^ ^ ) ", " =( .. )= ", ' (")_(") '],
48
+ [" (\\__/) ", " ( - - ) ", " =( .. )= ", ' (")_(") '],
49
+ ],
50
+ speed: 400,
51
+ },
52
+ dragon: {
53
+ name: "龙",
54
+ emoji: "🐉",
55
+ frames: [
56
+ [" /^\\ /^\\ ", " < o o > ", " ( ~~ ) ", " `-vvvv-´ "],
57
+ [" /^\\ /^\\ ", " < ^ ^ > ", " ( ~~ ) ", " `-vvvv-´ "],
58
+ [" /^\\ /^\\ ", " < - - > ", " ( ~~ ) ", " `-vvvv-´ "],
59
+ ],
60
+ speed: 600,
61
+ },
62
+ owl: {
63
+ name: "猫头鹰",
64
+ emoji: "🦉",
65
+ frames: [
66
+ [" /\\ /\\ ", " ((o)(o)) ", " ( >< ) ", " `----´ "],
67
+ [" /\\ /\\ ", " ((^)(^)) ", " ( >< ) ", " `----´ "],
68
+ [" /\\ /\\ ", " ((-)(-)) ", " ( >< ) ", " `----´ "],
69
+ ],
70
+ speed: 700,
71
+ },
72
+ penguin: {
73
+ name: "企鹅",
74
+ emoji: "🐧",
75
+ frames: [
76
+ [" .---. ", " (o> o) ", " /( )\\ ", " `---´ "],
77
+ [" .---. ", " (^> ^) ", " /( )\\ ", " `---´ "],
78
+ [" .---. ", " (-> -) ", " /( )\\ ", " `---´ "],
79
+ ],
80
+ speed: 500,
81
+ },
82
+ duck: {
83
+ name: "鸭子",
84
+ emoji: "🦆",
85
+ frames: [
86
+ [" __ ", " <(o )___ ", " ( ._> ", " `--´ "],
87
+ [" __ ", " <(^ )___ ", " ( ._> ", " `--´ "],
88
+ [" __ ", " <(- )___ ", " ( ._> ", " `--´ "],
89
+ ],
90
+ speed: 450,
91
+ },
92
+ goose: {
93
+ name: "鹅",
94
+ emoji: "🦢",
95
+ frames: [
96
+ [" (o> ", " || ", " _(__)_ ", " ^^^^ "],
97
+ [" (^> ", " || ", " _(__)_ ", " ^^^^ "],
98
+ [" (- > ", " || ", " _(__)_ ", " ^^^^ "],
99
+ ],
100
+ speed: 500,
101
+ },
102
+ turtle: {
103
+ name: "乌龟",
104
+ emoji: "🐢",
105
+ frames: [
106
+ [" _,--._ ", " ( o o ) ", " /[______]\\ ", " `` `` "],
107
+ [" _,--._ ", " ( ^ ^ ) ", " /[______]\\ ", " `` `` "],
108
+ [" _,--._ ", " ( - - ) ", " /[______]\\ ", " `` `` "],
109
+ ],
110
+ speed: 800,
111
+ },
112
+ snail: {
113
+ name: "蜗牛",
114
+ emoji: "🐌",
115
+ frames: [
116
+ [" · .--. ", " \\ ( @ ) ", " \\_`--´ ", " ~~~~~~~ "],
117
+ [" ✦ .--. ", " \\ ( @ ) ", " \\_`--´ ", " ~~~~~~~ "],
118
+ [" ◉ .--. ", " \\ ( @ ) ", " \\_`--´ ", " ~~~~~~~ "],
119
+ ],
120
+ speed: 900,
121
+ },
122
+ capybara: {
123
+ name: "水豚",
124
+ emoji: "🦫",
125
+ frames: [
126
+ [" n______n ", " ( o o ) ", " ( oo ) ", " `------´ "],
127
+ [" n______n ", " ( ^ ^ ) ", " ( oo ) ", " `------´ "],
128
+ [" n______n ", " ( - - ) ", " ( oo ) ", " `------´ "],
129
+ ],
130
+ speed: 600,
131
+ },
132
+ ghost: {
133
+ name: "幽灵",
134
+ emoji: "👻",
135
+ frames: [
136
+ [" .----. ", " / o o \\ ", " | | ", " ~`~``~`~ "],
137
+ [" .----. ", " / ^ ^ \\ ", " | | ", " ~`~``~`~ "],
138
+ [" .----. ", " / - - \\ ", " | | ", " ~`~``~`~ "],
139
+ ],
140
+ speed: 500,
141
+ },
142
+ robot: {
143
+ name: "机器人",
144
+ emoji: "🤖",
145
+ frames: [
146
+ [" .[||]. ", " [ o o ] ", " [ ==== ] ", " `------´ "],
147
+ [" .[||]. ", " [ ^ ^ ] ", " [ ==== ] ", " `------´ "],
148
+ [" .[||]. ", " [ - - ] ", " [ ==== ] ", " `------´ "],
149
+ ],
150
+ speed: 400,
151
+ },
152
+ axolotl: {
153
+ name: "蝾螈",
154
+ emoji: "🦎",
155
+ frames: [
156
+ ["}~(______)~{", "}~(o .. o)~{", " ( .--. ) ", " (_/ \\_) "],
157
+ ["}~(______)~{", "}~(^ .. ^)~{", " ( .--. ) ", " (_/ \\_) "],
158
+ ["}~(______)~{", "}~(- .. -)~{", " ( .--. ) ", " (_/ \\_) "],
159
+ ],
160
+ speed: 600,
161
+ },
162
+ mushroom: {
163
+ name: "蘑菇",
164
+ emoji: "🍄",
165
+ frames: [
166
+ [" .-o-OO-o-. ", "(__________)", " |o o| ", " |____| "],
167
+ [" .-o-OO-o-. ", "(__________)", " |^ ^| ", " |____| "],
168
+ [" .-o-OO-o-. ", "(__________)", " |- -| ", " |____| "],
169
+ ],
170
+ speed: 700,
171
+ },
172
+ blob: {
173
+ name: "团子",
174
+ emoji: "🫠",
175
+ frames: [
176
+ [" .----. ", " ( o o ) ", " ( ) ", " `----´ "],
177
+ [" .----. ", " ( ^ ^ ) ", " ( ) ", " `----´ "],
178
+ [" .----. ", " ( - - ) ", " ( ) ", " `----´ "],
179
+ ],
180
+ speed: 500,
181
+ },
182
+ octopus: {
183
+ name: "章鱼",
184
+ emoji: "🐙",
185
+ frames: [
186
+ [" .----. ", " ( o o ) ", " (______) ", " /\\/\\/\\/\\ "],
187
+ [" .----. ", " ( ^ ^ ) ", " (______) ", " \\/\\/\\/\\/ "],
188
+ [" .----. ", " ( - - ) ", " (______) ", " /\\/\\/\\/\\ "],
189
+ ],
190
+ speed: 400,
191
+ },
192
+ chonk: {
193
+ name: "胖熊",
194
+ emoji: "🐻",
195
+ frames: [
196
+ [" /\\ /\\ ", " ( o o ) ", " ( .. ) ", " `------´ "],
197
+ [" /\\ /\\ ", " ( ^ ^ ) ", " ( .. ) ", " `------´ "],
198
+ [" /\\ /\\ ", " ( - - ) ", " ( .. ) ", " `------´ "],
199
+ ],
200
+ speed: 600,
201
+ },
202
+ cactus: {
203
+ name: "仙人掌",
204
+ emoji: "🌵",
205
+ frames: [
206
+ [" n ____ n ", " | |o o| | ", " |_| |_| ", " | | "],
207
+ [" n ____ n ", " | |^ ^| | ", " |_| |_| ", " | | "],
208
+ [" n ____ n ", " | |- -| | ", " |_| |_| ", " | | "],
209
+ ],
210
+ speed: 700,
211
+ },
212
+ };
213
+
214
+ // 随机选择动物
215
+ const petKeys = Object.keys(PETS);
216
+ let currentPet: string;
217
+ let currentFrame = 0;
218
+ let intervalId: ReturnType<typeof setInterval> | null = null;
219
+ let isShiny = false;
220
+ let currentHat = "none";
221
+
222
+ function getRandomPet(): string {
223
+ return petKeys[Math.floor(Math.random() * petKeys.length)]!;
224
+ }
225
+
226
+ function getRandomHat(): string {
227
+ const hatChance = Math.random();
228
+ if (hatChance < 0.05) return "crown";
229
+ if (hatChance < 0.1) return "wizard";
230
+ if (hatChance < 0.15) return "tophat";
231
+ if (hatChance < 0.2) return "halo";
232
+ if (hatChance < 0.25) return "beanie";
233
+ if (hatChance < 0.3) return "tinyduck";
234
+ if (hatChance < 0.35) return "propeller";
235
+ return "none";
236
+ }
237
+
238
+ function getHatNames(): Record<string, string> {
239
+ return {
240
+ crown: "皇冠",
241
+ wizard: "魔法帽",
242
+ tophat: "礼帽",
243
+ halo: "光环",
244
+ beanie: "无檐帽",
245
+ tinyduck: "小鸭帽",
246
+ propeller: "螺旋桨帽",
247
+ none: "",
248
+ };
249
+ }
250
+
251
+ function getNextFrame(): string[] {
252
+ const pet = PETS[currentPet]!;
253
+ const frames = pet.frames;
254
+ currentFrame = (currentFrame + 1) % frames.length;
255
+ const frame = [...frames[currentFrame]!];
256
+
257
+ // 添加帽子(如果有)
258
+ if (currentHat !== "none" && frame.length > 0 && frame[0] && !frame[0].trim()) {
259
+ frame[0] = HATS[currentHat] ?? frame[0]!;
260
+ }
261
+
262
+ // 闪亮特效
263
+ if (isShiny) {
264
+ return frame.map((line) => `\x1b[38;2;255;215;0m${line}\x1b[0m`);
265
+ }
266
+
267
+ return frame;
268
+ }
269
+
270
+ function showPetNotification(ctx: ExtensionAPI["on"] extends (event: string, cb: (event: any, ctx: infer Ctx) => void) => void ? Ctx : never) {
271
+ const pet = PETS[currentPet]!;
272
+ const hatNames = getHatNames();
273
+ let msg = `${pet.emoji} 本次宠物:${pet.name}`;
274
+ if (isShiny) msg += " ✨(闪亮)";
275
+ if (currentHat !== "none") msg += ` + ${hatNames[currentHat]}`;
276
+ ctx.ui.notify(msg, "info");
277
+ }
278
+
279
+ function startPetAnimation(ctx: any) {
280
+ const pet = PETS[currentPet]!;
281
+
282
+ // 显示初始精灵
283
+ const initialSprite = getNextFrame();
284
+ ctx.ui.setWidget("random-pet", initialSprite, { placement: "belowEditor" });
285
+
286
+ // 启动动画
287
+ let animationCtx = ctx;
288
+ if (intervalId) {
289
+ clearInterval(intervalId);
290
+ }
291
+ intervalId = setInterval(() => {
292
+ try {
293
+ const nextSprite = getNextFrame();
294
+ animationCtx.ui.setWidget("random-pet", nextSprite, { placement: "belowEditor" });
295
+ } catch (e) {
296
+ if (intervalId) {
297
+ clearInterval(intervalId);
298
+ intervalId = null;
299
+ }
300
+ }
301
+ }, pet.speed);
302
+ }
303
+
304
+ export default function (pi: ExtensionAPI) {
305
+ // 注册 /pet 命令
306
+ pi.registerCommand("pet", {
307
+ description: "切换随机宠物",
308
+ usage: "/pet",
309
+ handler: async (_args, ctx) => {
310
+ // 清理旧动画
311
+ if (intervalId) {
312
+ clearInterval(intervalId);
313
+ intervalId = null;
314
+ }
315
+
316
+ // 随机选择新宠物
317
+ const oldPet = PETS[currentPet]!;
318
+ currentPet = getRandomPet();
319
+ isShiny = Math.random() < 0.1;
320
+ currentHat = getRandomHat();
321
+ currentFrame = 0;
322
+
323
+ const pet = PETS[currentPet]!;
324
+
325
+ // 启动新动画
326
+ startPetAnimation(ctx);
327
+
328
+ // 显示切换提示
329
+ const hatNames = getHatNames();
330
+ let msg = `${oldPet.emoji}${oldPet.name} → ${pet.emoji}${pet.name}`;
331
+ if (isShiny) msg += " ✨(闪亮)";
332
+ if (currentHat !== "none") msg += ` + ${hatNames[currentHat]}`;
333
+ ctx.ui.notify(msg, "info");
334
+ },
335
+ });
336
+
337
+ pi.on("session_start", (_event, ctx) => {
338
+ // 随机选择动物
339
+ currentPet = getRandomPet();
340
+ isShiny = Math.random() < 0.1;
341
+ currentHat = getRandomHat();
342
+ currentFrame = 0;
343
+
344
+ // 启动动画
345
+ startPetAnimation(ctx);
346
+
347
+ // 显示提示
348
+ const pet = PETS[currentPet]!;
349
+ const hatNames = getHatNames();
350
+ let msg = `${pet.emoji} 本次宠物:${pet.name}`;
351
+ if (isShiny) msg += " ✨(闪亮)";
352
+ if (currentHat !== "none") msg += ` + ${hatNames[currentHat]}`;
353
+ ctx.ui.notify(msg, "info");
354
+ });
355
+
356
+ pi.on("session_shutdown", () => {
357
+ if (intervalId) {
358
+ clearInterval(intervalId);
359
+ intervalId = null;
360
+ }
361
+ });
362
+ }
@@ -10,7 +10,7 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
10
10
 
11
11
  const AICODE_TIPS = [
12
12
  "@ 快速搜索项目文件",
13
- "Ctrl+L 快速切换备选模型,
13
+ "Ctrl+L 快速切换备选模型",
14
14
  "Ctrl+P/Shift+Ctrl+P 切换范围模型",
15
15
  "Shift+Tab 切换思考级别( 需 models.json配置 reasoning 启用)",
16
16
  "Ctrl+O 展开/折叠工具输出",
@@ -107,7 +107,7 @@ const AICODE_TIPS = [
107
107
  "计划模式自动生成 Todo 列表,跟踪实施步骤和完成进度",
108
108
  "/goal 设置长期目标,跟踪进度、时间和 token 消耗",
109
109
  "/goal --tokens=50K 设置带 token 预算限制的目标",
110
- "Goal 扩展支持 paused/resumed 状态,方便暂停和恢复长期任务 (推荐使用)",,
110
+ "Goal 扩展支持 paused/resumed 状态,方便暂停和恢复长期任务 (推荐使用)",
111
111
  "Goal 扩展显示实时进度:已完成百分比、已用时间和 token 消耗 (推荐使用)",
112
112
  "⚠️ 使用 advisor 获取第二视角评估,避免决策盲点",
113
113
  "⚠️ 使用 plan 模式强制分析流程,避免冲动式编码(推荐使用)",
@@ -164,7 +164,7 @@ export default function (pi: ExtensionAPI) {
164
164
  let currentTipIndex = -1;
165
165
  let autoHideTimeoutId: NodeJS.Timeout | undefined;
166
166
  let autoShowTimeoutId: NodeJS.Timeout | undefined;
167
- let currentCtx: ExtensionContext | undefined;
167
+ let currentUI: any = undefined; // 保存当前有效的 UI context
168
168
  let isAutoPlaying = false;
169
169
 
170
170
  // 基于 #49bccf (73, 188, 207) 的随机颜色生成
@@ -178,8 +178,8 @@ export default function (pi: ExtensionAPI) {
178
178
  };
179
179
 
180
180
  const hideTip = () => {
181
- if (!currentCtx?.hasUI) return;
182
- currentCtx.ui.setWidget("aicode-tips", [], { placement: "aboveEditor" });
181
+ if (!currentUI) return;
182
+ currentUI.setWidget("aicode-tips", [], { placement: "aboveEditor" });
183
183
  };
184
184
 
185
185
  const clearAllTimers = () => {
@@ -202,7 +202,7 @@ export default function (pi: ExtensionAPI) {
202
202
  };
203
203
 
204
204
  const showRandomTip = (isInternalCall = false) => {
205
- if (!currentCtx?.hasUI) return;
205
+ if (!currentUI) return;
206
206
 
207
207
  // 清除之前的定时器(但内部轮播调用时不清除,避免中断轮播)
208
208
  if (!isInternalCall) {
@@ -218,11 +218,20 @@ export default function (pi: ExtensionAPI) {
218
218
  // 使用随机颜色
219
219
  const color = getRandomTipColor();
220
220
  const coloredTip = `${color}tips: ${tip}\x1b[0m`;
221
- currentCtx.ui.setWidget("aicode-tips", [coloredTip], { placement: "aboveEditor" });
221
+ currentUI.setWidget("aicode-tips", [coloredTip], { placement: "aboveEditor" });
222
222
 
223
223
  // 自动轮播:10 秒后直接显示下一条
224
224
  if (isAutoPlaying) {
225
- autoHideTimeoutId = setTimeout(() => showRandomTip(true), 10000);
225
+ autoHideTimeoutId = setTimeout(() => {
226
+ try {
227
+ showRandomTip(true);
228
+ } catch (error) {
229
+ // 如果出现错误(比如 stale context),清理定时器
230
+ console.error('aicode-tips extension error:', error);
231
+ stopAutoPlay();
232
+ currentUI = undefined;
233
+ }
234
+ }, 10000);
226
235
  }
227
236
  };
228
237
 
@@ -238,22 +247,22 @@ export default function (pi: ExtensionAPI) {
238
247
 
239
248
  // 会话开始时启动自动轮播
240
249
  pi.on("session_start", (_event, ctx) => {
241
- currentCtx = ctx;
250
+ currentUI = ctx.ui;
242
251
  startAutoPlay();
243
252
  });
244
253
 
245
254
  // 会话结束时清理
246
255
  pi.on("session_shutdown", () => {
247
256
  stopAutoPlay();
248
- currentCtx?.ui.setWidget("aicode-tips", undefined);
249
- currentCtx = undefined;
257
+ currentUI?.setWidget("aicode-tips", undefined);
258
+ currentUI = undefined;
250
259
  });
251
260
 
252
261
  // 手动切换技巧
253
262
  pi.registerCommand("tip", {
254
263
  description: "显示一个新的 aicode 使用技巧",
255
264
  handler: async (_args, ctx) => {
256
- currentCtx = ctx;
265
+ currentUI = ctx.ui;
257
266
  showRandomTip();
258
267
  },
259
268
  });
@@ -15,38 +15,6 @@ export default function (pi: ExtensionAPI) {
15
15
  // === 记录开始时间 ===
16
16
  let agentStartTime = 0;
17
17
 
18
- pi.on("agent_start", async () => {
19
- agentStartTime = Date.now();
20
- });
21
-
22
- // === 消息完成通知(只在最后一条显示)===
23
-
24
- pi.on("agent_end", async (event, ctx) => {
25
- try {
26
- // 计算耗时
27
- const elapsed = Date.now() - agentStartTime;
28
- let timeStr: string;
29
- if (elapsed < 1000) {
30
- timeStr = `${elapsed}ms`;
31
- } else if (elapsed < 60000) {
32
- timeStr = `${(elapsed / 1000).toFixed(1)}s`;
33
- } else {
34
- const mins = Math.floor(elapsed / 60000);
35
- const secs = Math.floor((elapsed % 60000) / 1000);
36
- timeStr = `${mins}m ${secs}s`;
37
- }
38
-
39
- // 格式化完整时间
40
- const now = new Date();
41
- const timeStr2 = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
42
-
43
- // agent_end 表示一轮对话结束,显示完成提示
44
- ctx.ui.notify(`${timeStr2} | ✨ 消息处理完成 (${timeStr})`, "info");
45
- } catch (e) {
46
- // 忽略错误(ctx 可能已过期)
47
- }
48
- });
49
-
50
18
  // === 拦截工具调用 ===
51
19
 
52
20
  pi.on("tool_call", async (event, ctx) => {
@@ -0,0 +1,87 @@
1
+ /**
2
+ * @author: xujianjiang
3
+ * Token 性能统计扩展
4
+ *
5
+ * 功能说明:
6
+ * - 在每轮对话结束时显示 Token 使用统计
7
+ * - 显示处理速度(token/秒)
8
+ * - 显示输入输出 Token 数量
9
+ * - 显示耗时(智能格式化:ms/秒/分+秒)
10
+ *
11
+ * 输出格式示例:
12
+ * 2026-06-08T16:19:54 | ✨ 处理完成 | 15.2 token/秒 | ↓out 1,234 | ↑in 567 | 11.2秒
13
+ *
14
+ * 放置位置:config/agent/extensions/
15
+ */
16
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
17
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
18
+
19
+ function isAssistantMessage(message: unknown): message is AssistantMessage {
20
+ if (!message || typeof message !== "object") return false;
21
+ const role = (message as { role?: unknown }).role;
22
+ return role === "assistant";
23
+ }
24
+
25
+ function formatDateTime(date: Date): string {
26
+ const year = date.getFullYear();
27
+ const month = String(date.getMonth() + 1).padStart(2, "0");
28
+ const day = String(date.getDate()).padStart(2, "0");
29
+ const hour = String(date.getHours()).padStart(2, "0");
30
+ const minute = String(date.getMinutes()).padStart(2, "0");
31
+ const second = String(date.getSeconds()).padStart(2, "0");
32
+ return `${year}-${month}-${day}T${hour}:${minute}:${second}`;
33
+ }
34
+
35
+ function formatDuration(ms: number): string {
36
+ if (ms < 1000) {
37
+ return `${ms}ms`;
38
+ } else if (ms < 60000) {
39
+ return `${(ms / 1000).toFixed(1)}秒`;
40
+ } else {
41
+ const mins = Math.floor(ms / 60000);
42
+ const secs = Math.floor((ms % 60000) / 1000);
43
+ return `${mins}分${secs}秒`;
44
+ }
45
+ }
46
+
47
+ export default function (pi: ExtensionAPI) {
48
+ let agentStartMs: number | null = null;
49
+
50
+ pi.on("agent_start", () => {
51
+ agentStartMs = Date.now();
52
+ });
53
+
54
+ pi.on("agent_end", (event, ctx) => {
55
+ if (!ctx.hasUI) return;
56
+ if (agentStartMs === null) return;
57
+
58
+ const elapsedMs = Date.now() - agentStartMs;
59
+ agentStartMs = null;
60
+ if (elapsedMs <= 0) return;
61
+
62
+ let input = 0;
63
+ let output = 0;
64
+ let cacheRead = 0;
65
+ let cacheWrite = 0;
66
+ let totalTokens = 0;
67
+
68
+ for (const message of event.messages) {
69
+ if (!isAssistantMessage(message)) continue;
70
+ input += message.usage.input || 0;
71
+ output += message.usage.output || 0;
72
+ cacheRead += message.usage.cacheRead || 0;
73
+ cacheWrite += message.usage.cacheWrite || 0;
74
+ totalTokens += message.usage.totalTokens || 0;
75
+ }
76
+
77
+ if (output <= 0) return;
78
+
79
+ const elapsedSeconds = elapsedMs / 1000;
80
+ const tokensPerSecond = output / elapsedSeconds;
81
+ const now = new Date();
82
+ const timeStr = formatDateTime(now);
83
+ const durationStr = formatDuration(elapsedMs);
84
+ const message = `${timeStr} | ✨ 处理完成 | ${tokensPerSecond.toFixed(2)} token/秒 | ↑in ${input.toLocaleString()} |↓out ${output.toLocaleString()} | ${durationStr}`;
85
+ ctx.ui.notify(message, "info");
86
+ });
87
+ }
@@ -2,11 +2,15 @@
2
2
  * @author: xujianjiang
3
3
  * working-message
4
4
  *
5
- * 自定义工作消息,格式类似 Claude Code: ✶ xxx… (时间 · tokens)
5
+ * 自定义工作消息,格式类似 Claude Code: ✶ xxx… (时间)
6
+ *
7
+ * 改进:使用 dispose 模式防止内存泄漏
8
+ * - 使用明确类型,避免 any
9
+ * - 实现可靠的定时器清理机制
10
+ * - 双重保障确保资源释放
6
11
  */
7
12
 
8
- import type { ExtensionAPI, TurnEndEvent } from "@earendil-works/pi-coding-agent";
9
- import type { AssistantMessage } from "@earendil-works/pi-ai";
13
+ import type { ExtensionAPI, ExtensionContext, TurnEndEvent } from "@earendil-works/pi-coding-agent";
10
14
 
11
15
  // 主题色 #49bccf
12
16
  const accentColor = "\x1b[38;2;73;188;207m";
@@ -44,11 +48,100 @@ const workingMessages = [
44
48
  "Algorithm", "Architecture", "Database", "Cache", "Queue", "Microservice",
45
49
  ];
46
50
 
47
- let intervalId: ReturnType<typeof setInterval> | null = null;
48
- let startTime = 0;
49
- let totalTokens = 0;
50
- let currentUI: any = null; // 保存当前有效的 UI context
51
+ /**
52
+ * 工作消息状态管理器 - 使用 dispose 模式
53
+ * 确保定时器和 UI 引用的生命周期管理,防止内存泄漏
54
+ */
55
+ class WorkingMessageStateManager {
56
+ // ✅ 使用明确类型,避免 any
57
+ private _ui: ExtensionContext['ui'] | null = null;
58
+ private _intervalId: ReturnType<typeof setInterval> | null = null;
59
+ private _startTime: number = 0;
60
+ private _disposed: boolean = false;
61
+
62
+ // Getter 方法
63
+ get ui(): ExtensionContext['ui'] | null {
64
+ return this._ui;
65
+ }
66
+
67
+ get intervalId(): ReturnType<typeof setInterval> | null {
68
+ return this._intervalId;
69
+ }
70
+
71
+ get startTime(): number {
72
+ return this._startTime;
73
+ }
74
+
75
+ isDisposed(): boolean {
76
+ return this._disposed;
77
+ }
78
+
79
+ // Setter 方法(检查 disposed 状态)
80
+ setUI(ui: ExtensionContext['ui'] | null): void {
81
+ if (this._disposed) {
82
+ console.warn('[working-msg] State already disposed, cannot set UI');
83
+ return;
84
+ }
85
+ this._ui = ui;
86
+ }
87
+
88
+ setInterval(intervalId: ReturnType<typeof setInterval> | null): void {
89
+ if (this._disposed) {
90
+ console.warn('[working-msg] State already disposed, cannot set interval');
91
+ return;
92
+ }
93
+ this._intervalId = intervalId;
94
+ }
95
+
96
+ setStartTime(startTime: number): void {
97
+ if (this._disposed) {
98
+ console.warn('[working-msg] State already disposed, cannot set start time');
99
+ return;
100
+ }
101
+ this._startTime = startTime;
102
+ }
103
+
104
+ /**
105
+ * dispose() - 清理所有资源
106
+ * ✅ 双重保障:清理定时器 + 清理 UI 引用
107
+ * ✅ disposed 标记防止重复清理
108
+ */
109
+ dispose(): void {
110
+ if (this._disposed) {
111
+ return; // 防止重复 dispose
112
+ }
51
113
 
114
+ // ✅ 清理定时器(双重保障)
115
+ if (this._intervalId) {
116
+ clearInterval(this._intervalId);
117
+ this._intervalId = null;
118
+ }
119
+
120
+ // ✅ 清理 UI 引用
121
+ if (this._ui) {
122
+ try {
123
+ this._ui.setWorkingMessage(undefined);
124
+ } catch (error) {
125
+ // ✅ 忽略错误,确保清理继续
126
+ console.warn('[working-msg] Error clearing working message:', error);
127
+ }
128
+ this._ui = null;
129
+ }
130
+
131
+ this._disposed = true;
132
+ }
133
+
134
+ /**
135
+ * reset() - 重置状态(用于新的 turn)
136
+ */
137
+ reset(): void {
138
+ this.dispose(); // 先清理旧资源
139
+ this._disposed = false; // 重置 disposed 标记
140
+ this._startTime = 0;
141
+ }
142
+ }
143
+
144
+ // 工具函数
52
145
  function getNextMessage(): string {
53
146
  const randomIndex = Math.floor(Math.random() * workingMessages.length);
54
147
  return workingMessages[randomIndex];
@@ -61,76 +154,77 @@ function formatTime(seconds: number): string {
61
154
  return `${mins} m ${secs} s`;
62
155
  }
63
156
 
64
- function formatTokens(tokens: number): string {
65
- if (tokens < 1000) return `${tokens}`;
66
- return `${(tokens / 1000).toFixed(1)}k`;
157
+ /**
158
+ * ✅ 改进的定时器逻辑
159
+ * - 不依赖 try-catch
160
+ * - 检查 disposed 状态
161
+ * - 使用 state.ui(明确类型)
162
+ */
163
+ function startInterval(state: WorkingMessageStateManager): void {
164
+ if (state.isDisposed() || !state.ui) {
165
+ return;
166
+ }
167
+
168
+ const intervalId = setInterval(() => {
169
+ // ✅ 每次执行前检查 disposed 和 UI 状态
170
+ if (state.isDisposed() || !state.ui) {
171
+ // ✅ 自动清理
172
+ state.dispose();
173
+ return;
174
+ }
175
+
176
+ // ✅ 安全执行(明确类型)
177
+ const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
178
+ const timeStr = formatTime(elapsed);
179
+ const msg = `${accentColor} ✶ ${getNextMessage()}…${reset} (${timeStr})`;
180
+
181
+ try {
182
+ state.ui.setWorkingMessage(msg);
183
+ } catch (error) {
184
+ // ✅ 如果 UI 调用失败,自动清理
185
+ console.warn('[working-msg] UI call failed, disposing:', error);
186
+ state.dispose();
187
+ }
188
+ }, 3000);
189
+
190
+ state.setInterval(intervalId);
67
191
  }
68
192
 
193
+ /**
194
+ * 扩展主函数 - 使用 dispose 模式管理状态
195
+ */
69
196
  export default function workingMessageExtension(pi: ExtensionAPI) {
70
- // 监听 session_shutdown 事件,清理 interval
197
+ // ✅ 单一状态管理器
198
+ const state = new WorkingMessageStateManager();
199
+
200
+ // ✅ session_shutdown - 清理所有资源
71
201
  pi.on("session_shutdown", async (_event, ctx) => {
72
- if (intervalId) {
73
- clearInterval(intervalId);
74
- intervalId = null;
75
- }
76
- currentUI = null;
202
+ state.dispose();
77
203
  });
78
204
 
79
- // turn_start 时开始循环切换消息
205
+ // ✅ turn_start - 初始化状态并启动定时器
80
206
  pi.on("turn_start", async (_event, ctx) => {
81
- if (!ctx.ui) return;
82
-
83
- // 保存当前有效的 UI context
84
- currentUI = ctx.ui;
207
+ if (!ctx.ui) {
208
+ return;
209
+ }
85
210
 
86
- // 记录开始时间
87
- startTime = Date.now();
88
- totalTokens = 0;
211
+ // ✅ 重置状态(清理旧资源)
212
+ state.reset();
213
+ state.setUI(ctx.ui); // ✅ 明确类型:ExtensionContext['ui']
214
+ state.setStartTime(Date.now());
89
215
 
90
- // 立即设置第一条消息
91
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
216
+ // 设置第一条消息
217
+ const elapsed = Math.floor((Date.now() - state.startTime) / 1000);
92
218
  const timeStr = formatTime(elapsed);
93
- const tokensStr = formatTokens(totalTokens);
94
- const msg = `${accentColor} ✶ ${getNextMessage()}…${reset} (${timeStr} · ↓ ${tokensStr} tokens)`;
95
- currentUI.setWorkingMessage(msg);
96
-
97
- // 每3秒更新一次 - 使用 try-catch 处理 stale context
98
- if (!intervalId) {
99
- intervalId = setInterval(() => {
100
- try {
101
- // 使用保存的 currentUI 而不是捕获的 ctx.ui
102
- if (!currentUI) return;
103
-
104
- // 获取当前 tokens 使用量
105
- // 注意:这里不再访问 ctx.sessionManager,避免 stale context 错误
106
- // tokens 数量通过其他方式获取,或者保持为 0
107
-
108
- const elapsed = Math.floor((Date.now() - startTime) / 1000);
109
- const timeStr = formatTime(elapsed);
110
- const tokensStr = formatTokens(totalTokens);
111
- const msg = `${accentColor} ✶ ${getNextMessage()}…${reset} (${timeStr} · ↓ ${tokensStr} tokens)`;
112
- currentUI.setWorkingMessage(msg);
113
- } catch (error) {
114
- // 如果出现错误(比如 stale context),清理 interval
115
- console.error('working-msg extension error:', error);
116
- if (intervalId) {
117
- clearInterval(intervalId);
118
- intervalId = null;
119
- }
120
- currentUI = null;
121
- }
122
- }, 3000);
123
- }
219
+ const msg = `${accentColor} ✶ ${getNextMessage()}…${reset} (${timeStr})`;
220
+ state.ui?.setWorkingMessage(msg);
221
+
222
+ // ✅ 启动定时器
223
+ startInterval(state);
124
224
  });
125
225
 
126
- // turn结束时停止
226
+ // ✅ turn_end - 清理所有资源
127
227
  pi.on("turn_end", async (_event: TurnEndEvent, ctx) => {
128
- if (intervalId) {
129
- clearInterval(intervalId);
130
- intervalId = null;
131
- }
132
- // 恢复默认消息
133
- currentUI?.setWorkingMessage(undefined);
134
- currentUI = null;
228
+ state.dispose();
135
229
  });
136
- }
230
+ }
@@ -10,7 +10,8 @@
10
10
  "~/.aicode-cli/agent/extensions/custom-footer.ts",
11
11
  "~/.aicode-cli/agent/extensions/custom.ts",
12
12
  "~/.aicode-cli/agent/extensions/review.ts",
13
- "~/.aicode-cli/agent/extensions/working-msg.ts"
13
+ "~/.aicode-cli/agent/extensions/working-msg.ts",
14
+ "-extensions/aicode-pets.ts"
14
15
  ],
15
16
  "packages":[
16
17
  "npm:pi-subagents@0.17.1",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ia-ccun/code-agent-cli",
3
- "version": "0.0.52-beta.1",
3
+ "version": "0.0.52-beta.3",
4
4
  "description": "AI Coding Agent CLI - 基于OpenClaw🦞底层Agent原理封装的编码智能体(供学习使用)。",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -6,6 +6,7 @@ import { symlink as fsSymlink } from 'fs/promises';
6
6
  import { join, dirname, relative } from 'path';
7
7
  import { homedir } from 'os';
8
8
  import { fileURLToPath } from 'url';
9
+ import { spawn } from 'child_process';
9
10
 
10
11
  // Get __dirname equivalent for ES modules
11
12
  const __filename = fileURLToPath(import.meta.url);
@@ -463,6 +464,63 @@ logSecurity('install_complete', {
463
464
  piDir,
464
465
  });
465
466
 
467
+ // ============================================================================
468
+ // Auto-install codegraph (optional dependency)
469
+ // ============================================================================
470
+
471
+ const CODEGRAPH_PKG = '@colbymchenry/codegraph@0.9.7';
472
+
473
+ async function installCodegraph() {
474
+ return new Promise((resolve) => {
475
+ const child = spawn('npm', ['i', '-g', CODEGRAPH_PKG], {
476
+ stdio: 'pipe',
477
+ shell: process.platform === 'win32',
478
+ });
479
+
480
+ let stderr = '';
481
+
482
+ child.stderr.on('data', (data) => {
483
+ stderr += data.toString();
484
+ });
485
+
486
+ child.on('close', (code) => {
487
+ if (code === 0) {
488
+ console.log('✓ Installed codegraph globally,See UniDoc《2026.5 aicode+codegraph 安装与使用》');
489
+ logSecurity('codegraph_install', {
490
+ package: CODEGRAPH_PKG,
491
+ success: true
492
+ });
493
+ } else {
494
+ // 静默失败,不中断主流程
495
+ if (process.env.DEBUG) {
496
+ console.log(`⚠ Could not install codegraph: ${stderr.slice(0, 100)}`);
497
+ }
498
+ logSecurity('codegraph_install', {
499
+ package: CODEGRAPH_PKG,
500
+ success: false,
501
+ error: stderr.slice(0, 200)
502
+ }, 'warn');
503
+ }
504
+ resolve();
505
+ });
506
+
507
+ child.on('error', (err) => {
508
+ if (process.env.DEBUG) {
509
+ console.log(`⚠ Could not install codegraph: ${err.message}`);
510
+ }
511
+ logSecurity('codegraph_install', {
512
+ package: CODEGRAPH_PKG,
513
+ success: false,
514
+ error: err.message
515
+ }, 'warn');
516
+ resolve();
517
+ });
518
+ });
519
+ }
520
+
521
+ // Execute codegraph installation
522
+ await installCodegraph();
523
+
466
524
  console.log('\n✨ Setup complete!');
467
525
  console.log('📝 Configuration files are in ~/.aicode-cli/');
468
526
  console.log('🔗 ~/.pi is linked to ~/.aicode-cli (ensuring model configs work)');