@fanchao8609/agent_brain_sync 1.9.9 → 1.10.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.
- package/hooks/abs.pi.ts +533 -3
- package/package.json +1 -1
- package/src/store.js +2 -1
- package/src/userconfig.js +6 -3
package/hooks/abs.pi.ts
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
* 已删除(2026-09-18):收尾注入本身(agent_end + sendUserMessage deliverAs=followUp)。
|
|
11
11
|
* 实报「每次干活干一会出来, 任务就中断了」。两次同一个根因:
|
|
12
12
|
* **往对话里插一句话本身就是设计错误,不是频率问题** —— 改守卫(每轮→每会话)治不了它。
|
|
13
|
-
*
|
|
13
|
+
* 别再加回来(指 sendUserMessage 注入;静态 system prompt 内容不在此列,见下方 todo 指引)。
|
|
14
14
|
*/
|
|
15
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
|
|
16
|
-
import { appendFile, mkdir, stat } from "node:fs/promises"
|
|
16
|
+
import { appendFile, mkdir, readFile, stat } from "node:fs/promises"
|
|
17
17
|
import { homedir } from "node:os"
|
|
18
18
|
import { join } from "node:path"
|
|
19
19
|
import { spawn } from "node:child_process"
|
|
@@ -57,21 +57,550 @@ async function findBrain(cwd: string): Promise<string | null> {
|
|
|
57
57
|
*/
|
|
58
58
|
let agentEndSeen = false
|
|
59
59
|
|
|
60
|
+
/** “刚才调的是 todo 工具”标记 —— 由 tool_call 置位、tool_execution_end 消费。
|
|
61
|
+
* 同样在模块级,理由同上(闭包 flag 在重复注册时不共享)。 */
|
|
62
|
+
let pendingTodoTool = false
|
|
63
|
+
|
|
60
64
|
/** 会话边界重置埋点状态。
|
|
61
65
|
* 不重置 → 同进程第二个会话继承 true 永久不留痕(2026-09-13 实测过的坑)。 */
|
|
62
66
|
function resetThrottle(): void {
|
|
63
67
|
agentEndSeen = false
|
|
68
|
+
pendingTodoTool = false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// todo 及时性验证(2026-10-03):静态 system prompt 指引
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
// 待验证假设:abs 的 todo「不及时」不是工具问题,是**触发机制**问题 ——
|
|
75
|
+
// `abs_task` 的指引只存在于工具 description 里,模型常忽略;而 pi 原生工具的
|
|
76
|
+
// `promptGuidelines` 会进 system prompt 的 Guidelines 段,每轮都在。
|
|
77
|
+
//
|
|
78
|
+
// 与历史两次失败做法的**分界线**(必须守住):
|
|
79
|
+
// 删掉的两次都是 `sendUserMessage(deliverAs:"followUp")` —— **往对话里插话**,
|
|
80
|
+
// 抢走一个 turn、打断用户。本次是**静态 prompt 内容**:不新增消息、不抢 turn、
|
|
81
|
+
// 不产生任何对话条目,只是连同其它 Guidelines 一起渲染进 system prompt。
|
|
82
|
+
// 若将来要扩展,也绝不能退化成插话。
|
|
83
|
+
//
|
|
84
|
+
// 关掉即设 ABS_TODO_GUIDE=0。
|
|
85
|
+
// 工具名写 `abs_task`(不带 mcp__abs__ 前缀)—— 2026-10-03 审查发现:MCP 默认
|
|
86
|
+
// exposure=codemode,模型侧看到的就叫 abs_task;写错名字等于让模型去找不存在的工具。
|
|
87
|
+
const TODO_GUIDELINES = [
|
|
88
|
+
'Use `abs_task` to track multi-step work **before** you start it, not after: on the first file edit of a task, call action "start" with a short id.',
|
|
89
|
+
'Mark a task "done" immediately when it finishes — never batch completions at the end of a session.',
|
|
90
|
+
'Before starting a task, record the checkpoint with action "note" (which file, which step) so a later session can resume.',
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
/** 当前项目是否有 .brain 图谱 —— 有才加指引(没图谱的项目里这指引是噪音)。
|
|
94
|
+
*
|
|
95
|
+
* 为何不用工具名判断(2026-10-03 两次实测都错):
|
|
96
|
+
* ① event.systemPromptOptions.selectedTools 在 handler 里是**基线值**,真实表要等
|
|
97
|
+
* handler 之后(agent-session.js:1573)才回填 → 实测 tools=31 无 abs。
|
|
98
|
+
* ② 改用 pi.getActiveTools() 仍为假 —— MCP 默认 exposure=codemode,工具**本就
|
|
99
|
+
* 不进 active 工具表**(且 pi 的 MCP 是懒连接,实测提示 "lazy: from cache, not
|
|
100
|
+
* connected yet")。
|
|
101
|
+
* 结论:判断「abs 能不能用」不该看工具注册表,直接看**项目有没有 .brain/**。 */
|
|
102
|
+
async function hasBrain(cwd: string): Promise<boolean> {
|
|
103
|
+
try {
|
|
104
|
+
const st = await stat(join(cwd || process.cwd(), '.brain'))
|
|
105
|
+
return st.isDirectory()
|
|
106
|
+
} catch {
|
|
107
|
+
return false
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function injectTodoGuidelines(options: any): boolean {
|
|
112
|
+
if (String(process.env.ABS_TODO_GUIDE || '') === '0') return false
|
|
113
|
+
const list: string[] = options.promptGuidelines || (options.promptGuidelines = [])
|
|
114
|
+
for (const g of TODO_GUIDELINES) {
|
|
115
|
+
if (!list.includes(g)) list.push(g)
|
|
116
|
+
}
|
|
117
|
+
return true
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// todo 面板(2026-10-03):把 .brain/todo.md 的未完成任务显示在编辑器上方
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// 纯展示层:只读 todo.md,不写任何东西。数据源就是 abs 自己的看板 —— 不做第二套状态。
|
|
124
|
+
// 与 rpiv-todo 的区别:那个把状态存会话 transcript(跨会话丢失),我们用磁盘文件(可续接)。
|
|
125
|
+
// 关掉即设 ABS_TODO_PANEL=0。
|
|
126
|
+
const PANEL_KEY = "abs-todo-panel"
|
|
127
|
+
const PANEL_MAX_ROWS = 10
|
|
128
|
+
|
|
129
|
+
// 标题后缀昵称池(2026-10-03 用户提供)—— 每次**启动会话**随机抽一条,会话内保持不变。
|
|
130
|
+
// 不能每帧随机(panel 每帧都重渲染,那样会疯狂闪烁)。
|
|
131
|
+
const USER_NICKNAMES = [
|
|
132
|
+
'🛌 昼伏夜出型',
|
|
133
|
+
'😴 沾枕头就醒',
|
|
134
|
+
'🌙 熬夜当修仙',
|
|
135
|
+
'⏰ 闹钟十连败',
|
|
136
|
+
'🧟 永远睡不醒',
|
|
137
|
+
'☕ 靠咖啡续命',
|
|
138
|
+
'🧋 靠奶茶续命',
|
|
139
|
+
'🍚 干饭第一名',
|
|
140
|
+
'🥘 吃辣只敢微辣',
|
|
141
|
+
'🍳 炸厨房常客',
|
|
142
|
+
'🥗 间歇性减肥',
|
|
143
|
+
'🍜 深夜爱放毒',
|
|
144
|
+
'🧊 冰镇式养生',
|
|
145
|
+
'⌨️ 键盘上摸鱼',
|
|
146
|
+
'🐟 划水大师',
|
|
147
|
+
'💻 复制粘贴大师',
|
|
148
|
+
'🚽 带薪上厕所',
|
|
149
|
+
'🕕 到点就跑',
|
|
150
|
+
'📅 明天再说吧',
|
|
151
|
+
'🐷 坚决不加班',
|
|
152
|
+
'😇 表面在微笑',
|
|
153
|
+
'🤯 内心已掀桌',
|
|
154
|
+
'💸 月月过路财神',
|
|
155
|
+
'🏦 隐形负翁',
|
|
156
|
+
'🤑 转发锦鲤求暴富',
|
|
157
|
+
'📉 钱包越来越瘦',
|
|
158
|
+
'🛒 购物车首富',
|
|
159
|
+
'📱 网上冲浪选手',
|
|
160
|
+
'🛸 意念回复专家',
|
|
161
|
+
'📞 电话一响就慌',
|
|
162
|
+
'🙈 已读绝不回',
|
|
163
|
+
'🤝 只管埋头夹菜',
|
|
164
|
+
'📝 收藏从不用',
|
|
165
|
+
'📚 学了就忘',
|
|
166
|
+
'🧠 记忆力七秒',
|
|
167
|
+
'🔋 1%才去充电',
|
|
168
|
+
'🛍️ 拆快递狂魔',
|
|
169
|
+
'🧳 云旅游专家',
|
|
170
|
+
'🏋️ 办卡只去洗澡',
|
|
171
|
+
'🚶 步数常年垫底',
|
|
172
|
+
'💇 秃飞猛进',
|
|
173
|
+
'🏴☠️ 飞翔荷兰人',
|
|
174
|
+
'🐌 蜗牛速度选手',
|
|
175
|
+
'🦥 躺平专业户',
|
|
176
|
+
'🎮 打完这把就睡',
|
|
177
|
+
'🍿 吃瓜第一线',
|
|
178
|
+
'🔍 搜索两小时',
|
|
179
|
+
'🧩 爱钻牛角尖',
|
|
180
|
+
'💤 梦里也在编程',
|
|
181
|
+
'🚀 明早一定做',
|
|
182
|
+
'🧘 边熬夜边养生',
|
|
183
|
+
'📺 刷剧不眨眼',
|
|
184
|
+
'🎧 单曲循环中',
|
|
185
|
+
// ── 职场抱怨(2026-10-03 第二批)──
|
|
186
|
+
'🫠 靠不住选手',
|
|
187
|
+
'🧯 专业背锅侠',
|
|
188
|
+
'🪑 会议室钉子户',
|
|
189
|
+
'📎 工具人本人',
|
|
190
|
+
'🫥 存在感为零',
|
|
191
|
+
'🥄 打杂一把好手',
|
|
192
|
+
'🧮 人形Excel',
|
|
193
|
+
'📋 需求搬运工',
|
|
194
|
+
'🔧 万能补丁匠',
|
|
195
|
+
'🗣️ 会议两小时',
|
|
196
|
+
'💬 一言不合拉会',
|
|
197
|
+
'📊 对齐一整天',
|
|
198
|
+
'🌀 讨论没结论',
|
|
199
|
+
'🎯 追需求成瘾',
|
|
200
|
+
'📝 纪要孤儿',
|
|
201
|
+
'🔥 排期永远紧',
|
|
202
|
+
'⏳ 催到怀疑人生',
|
|
203
|
+
'🌃 下班天已黑',
|
|
204
|
+
'📆 周末待命',
|
|
205
|
+
'🚨 临时插需求',
|
|
206
|
+
'🧨 上线前改需求',
|
|
207
|
+
'📈 汇报全靠编',
|
|
208
|
+
'🎤 PPT大师',
|
|
209
|
+
'🤡 背锅第一名',
|
|
210
|
+
'🫡 收到马上办',
|
|
211
|
+
'🙃 领导说得对',
|
|
212
|
+
'👏 掌声最热烈',
|
|
213
|
+
'🏓 甩锅乒乓球',
|
|
214
|
+
'🙋 不背锅侠',
|
|
215
|
+
'📮 抄送战斗机',
|
|
216
|
+
'🧊 已读不回群',
|
|
217
|
+
'🔕 消息免打扰',
|
|
218
|
+
'🪫 电量剩5%',
|
|
219
|
+
'🧓 入行即养老',
|
|
220
|
+
' 心力耗尽',
|
|
221
|
+
'😮💨 叹气专业户',
|
|
222
|
+
'🪦 激情已入土',
|
|
223
|
+
'📤 简历常年挂着',
|
|
224
|
+
'🧳 随时准备跑路',
|
|
225
|
+
'💼 骑驴找马中',
|
|
226
|
+
'🪙 谈薪谈不动',
|
|
227
|
+
'🥲 涨薪等明年',
|
|
228
|
+
// ── 自嘲(2026-10-03 第三批)──
|
|
229
|
+
'🎲 编程全靠蒙',
|
|
230
|
+
'🙏 AI救我狗命',
|
|
231
|
+
'🐛 Bug制造机',
|
|
232
|
+
'🔮 玄学调参',
|
|
233
|
+
'📿 面向祈祷编程',
|
|
234
|
+
'🩹 补丁摞补丁',
|
|
235
|
+
'🤞 能跑就行',
|
|
236
|
+
'🗿 代码能跑别动',
|
|
237
|
+
'🎰 随机数人生',
|
|
238
|
+
'🧙 咒语背诵者',
|
|
239
|
+
'📖 文档从不看',
|
|
240
|
+
'⌨️ 只会复制粘贴',
|
|
241
|
+
'🫠 菜得安详',
|
|
242
|
+
'🥹 菜狗本狗',
|
|
243
|
+
'🐣 刚会写Hello',
|
|
244
|
+
'🧸 删库跑路预备',
|
|
245
|
+
'🪫 脑子已关机',
|
|
246
|
+
'🫥 假装很忙',
|
|
247
|
+
'🎭 专业演技派',
|
|
248
|
+
'🃏 气氛组组长',
|
|
249
|
+
'🧊 情绪稳定到麻木',
|
|
250
|
+
'🐟 摸鱼终身成就',
|
|
251
|
+
'🛋️ 沙发项目经理',
|
|
252
|
+
'📺 带薪看视频',
|
|
253
|
+
'🍵 带薪养生',
|
|
254
|
+
'💤 工位睡神',
|
|
255
|
+
'⏳ 明日复明日',
|
|
256
|
+
'🗓️ 周报最后写',
|
|
257
|
+
'📉 进度条倒退',
|
|
258
|
+
'🕳️ 坑是自己挖的',
|
|
259
|
+
'🧨 技术债主',
|
|
260
|
+
'💀 穷得响叮当',
|
|
261
|
+
'📵 社交电池耗尽',
|
|
262
|
+
'🍼 成年巨婴',
|
|
263
|
+
'🪞 镜子前叹气',
|
|
264
|
+
'🧦 袜子不成对',
|
|
265
|
+
'🥲 笑着活下去',
|
|
266
|
+
]
|
|
267
|
+
|
|
268
|
+
/** 本会话的昵称 —— 模块级(扩展重复注册时闭包变量不共享,同 agentEndSeen 的坑)。
|
|
269
|
+
* 空串 = 未抽(或 ABS_TODO_NICK=0 关掉)。 */
|
|
270
|
+
let sessionNickname = ''
|
|
271
|
+
|
|
272
|
+
/** 抽一条昵称。传 rnd 便于测试注入;默认 Math.random。关掉:ABS_TODO_NICK=0。 */
|
|
273
|
+
export function pickNickname(rnd: () => number = Math.random): string {
|
|
274
|
+
if (String(process.env.ABS_TODO_NICK || '') === '0') return ''
|
|
275
|
+
const i = Math.min(USER_NICKNAMES.length - 1, Math.floor(rnd() * USER_NICKNAMES.length))
|
|
276
|
+
return USER_NICKNAMES[Math.max(0, i)]
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** 解析 todo.md 的 ## Todo 区 —— 只收未完成行(- [ ]),Done 区不计。
|
|
280
|
+
*
|
|
281
|
+
* who 不为空时只收“我的任务”:行内有 [[who]] 的,或**完全没标作者**的
|
|
282
|
+
* (老任务/手写的没标,漏掉比多显示更糟)。标了别人的则不收。
|
|
283
|
+
*
|
|
284
|
+
* 断点行(缩进的 ↳)归属到它上一条任务,不单独成条。 */
|
|
285
|
+
export function parseOpenTasks(md: string, max = PANEL_MAX_ROWS, who = ''): {
|
|
286
|
+
total: number
|
|
287
|
+
rows: { state: string; id: string; desc: string; note: string }[]
|
|
288
|
+
hidden: number
|
|
289
|
+
} {
|
|
290
|
+
// 两阶段:先无过滤地按序解析(断点归属需要“上一条”是原文里真正的前一条),
|
|
291
|
+
// 再按作者过滤。若边解析边过滤,被滤掉的条目下方的断点会挂到它上面那一条
|
|
292
|
+
// —— 实测:别人任务的断点会显示在我的任务下面(2026-10-03 审查发现)。
|
|
293
|
+
type Row = { state: string; id: string; desc: string; note: string; authors: string[] }
|
|
294
|
+
const parsed: Row[] = []
|
|
295
|
+
let inTodo = false
|
|
296
|
+
for (const raw of String(md || '').split('\n')) {
|
|
297
|
+
const line = raw.trimEnd()
|
|
298
|
+
if (/^##\s+Todo\s*$/.test(line)) { inTodo = true; continue }
|
|
299
|
+
if (/^##\s+/.test(line)) { inTodo = false; continue }
|
|
300
|
+
if (!inTodo) continue
|
|
301
|
+
// 断点行:` ↳ 断点: …`(缩进)→ 挂到上一条任务
|
|
302
|
+
const note = line.match(/^\s+↳\s*断点:\s*(.*)$/)
|
|
303
|
+
if (note) {
|
|
304
|
+
const last = parsed[parsed.length - 1]
|
|
305
|
+
if (last && !last.note) last.note = note[1].trim()
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
// `- [ ] [状态] <id> …`;状态标记可能缺省
|
|
309
|
+
const m = line.match(/^-\s+\[\s\]\s+(?:\[([^\]]+)\]\s+)?(\S+)\s*(.*)$/)
|
|
310
|
+
if (!m) continue
|
|
311
|
+
const state = m[1] || '进行中'
|
|
312
|
+
const id = m[2]
|
|
313
|
+
const body = m[3] || ''
|
|
314
|
+
const authors = [...body.matchAll(/\[\[([^\]]+)\]\]/g)].map((x) => x[1])
|
|
315
|
+
// 剥掉作者标记(面板上碍眼且无信息量),再取 `—` 后正文
|
|
316
|
+
const desc = body
|
|
317
|
+
.replace(/\[\[[^\]]+\]\]/g, '')
|
|
318
|
+
.replace(/^\s*—\s*/, '')
|
|
319
|
+
.replace(/\s*\(认领\s*\d{4}-\d{2}-\d{2}\)\s*$/, '')
|
|
320
|
+
.trim()
|
|
321
|
+
parsed.push({ state, id, desc, note: '', authors })
|
|
322
|
+
}
|
|
323
|
+
const mine = who
|
|
324
|
+
? parsed.filter((r) => r.authors.length === 0 || r.authors.includes(who))
|
|
325
|
+
: parsed
|
|
326
|
+
const rows = mine.map(({ state, id, desc, note }) => ({ state, id, desc, note }))
|
|
327
|
+
return { total: rows.length, rows: rows.slice(0, max), hidden: Math.max(0, rows.length - max) }
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** 当前使用者名(读 ~/.abs/config.json 的 user,ABS_USER 环境变量优先)。
|
|
331
|
+
* 读不到返回空串 —— 那时不做作者过滤(宁可全显示,也别因配置缺失而面板空白)。 */
|
|
332
|
+
async function currentUser(): Promise<string> {
|
|
333
|
+
const env = String(process.env.ABS_USER || '').trim()
|
|
334
|
+
if (env) return env
|
|
335
|
+
try {
|
|
336
|
+
const dir = process.env.ABS_CONFIG_DIR || join(homedir(), '.abs')
|
|
337
|
+
const raw = await readFile(join(dir, 'config.json'), 'utf8')
|
|
338
|
+
const u = JSON.parse(raw).user
|
|
339
|
+
return typeof u === 'string' ? u.trim() : ''
|
|
340
|
+
} catch {
|
|
341
|
+
return ''
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** 按显示宽度截断(自己实现,不 import @earendil-works/pi-tui)。
|
|
346
|
+
*
|
|
347
|
+
* 为何不直接用 pi-tui 的 truncateToWidth(2026-10-03 实测):那是 pi 内部 alias,
|
|
348
|
+
* 裸包名只在 pi 的 jiti loader 下能解析 —— 测试环境用原生 node 加载扩展会
|
|
349
|
+
* ERR_MODULE_NOT_FOUND(实测挂了 6 个测试)。依赖宿主内部 alias 太脆。
|
|
350
|
+
*
|
|
351
|
+
* 宽度规则:CJK/全角记 2 列,其余记 1 列。足够让面板不溢出(不能处理 emoji ZWJ 序列,
|
|
352
|
+
* 但面板里没有—— todo 内容是任务名与说明)。 */
|
|
353
|
+
function dispWidth(s: string): number {
|
|
354
|
+
let w = 0
|
|
355
|
+
let i = 0
|
|
356
|
+
while (i < s.length) {
|
|
357
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i))
|
|
358
|
+
if (m) { i += m[0].length; continue }
|
|
359
|
+
const cp = s.codePointAt(i) || 0
|
|
360
|
+
const ch = String.fromCodePoint(cp)
|
|
361
|
+
i += ch.length
|
|
362
|
+
// 常见全角/CJK 区间(足以覆盖中文任务名)
|
|
363
|
+
const wide =
|
|
364
|
+
(cp >= 0x1100 && cp <= 0x115f) ||
|
|
365
|
+
(cp >= 0x2e80 && cp <= 0xa4cf) ||
|
|
366
|
+
(cp >= 0xac00 && cp <= 0xd7a3) ||
|
|
367
|
+
(cp >= 0xf900 && cp <= 0xfaff) ||
|
|
368
|
+
(cp >= 0xfe30 && cp <= 0xfe6f) ||
|
|
369
|
+
(cp >= 0xff00 && cp <= 0xff60) ||
|
|
370
|
+
(cp >= 0xffe0 && cp <= 0xffe6) ||
|
|
371
|
+
(cp >= 0x1f300 && cp <= 0x1f64f) ||
|
|
372
|
+
(cp >= 0x1f900 && cp <= 0x1f9ff)
|
|
373
|
+
w += wide ? 2 : 1
|
|
374
|
+
}
|
|
375
|
+
return w
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** 去掉 ANSI 转义序列(\x1b[...m 等),否则宽度计算会把颜色码也算进去。 */
|
|
379
|
+
function stripAnsi(s: string): string {
|
|
380
|
+
// eslint-disable-next-line no-control-regex
|
|
381
|
+
return s.replace(/\x1b\[[0-9;]*m/g, '')
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** 控宽截断:超宽则尾部换省略号(省略号算 1 列)。
|
|
385
|
+
*
|
|
386
|
+
* 注意:入参可能含 ANSI 颜色码(我们的 head 就是 fg() 拼的)。颜色码占 0 列,
|
|
387
|
+
* 必须先剥掉再量宽 —— 否则每行都会被误判超宽而截断。
|
|
388
|
+
* 截断后不再恢复颜色(该行尾会失去颜色,但省略号前的内容色仍在)—— 可接受,
|
|
389
|
+
* 因为只有超长行才会走到这里。 */
|
|
390
|
+
function clipToWidth(s: string, width: number): string {
|
|
391
|
+
if (width <= 0) return ''
|
|
392
|
+
if (dispWidth(stripAnsi(s)) <= width) return s
|
|
393
|
+
const keep = width - 1
|
|
394
|
+
let out = ''
|
|
395
|
+
let w = 0
|
|
396
|
+
let i = 0
|
|
397
|
+
// 逐字符扫描,遇到 ANSI 序列原样跳过(不计宽)
|
|
398
|
+
while (i < s.length) {
|
|
399
|
+
const m = /^\x1b\[[0-9;]*m/.exec(s.slice(i))
|
|
400
|
+
if (m) { out += m[0]; i += m[0].length; continue }
|
|
401
|
+
const cp = s.codePointAt(i) || 0
|
|
402
|
+
const ch = String.fromCodePoint(cp)
|
|
403
|
+
i += ch.length
|
|
404
|
+
const cw = dispWidth(ch)
|
|
405
|
+
if (w + cw > keep) break
|
|
406
|
+
out += ch
|
|
407
|
+
w += cw
|
|
408
|
+
}
|
|
409
|
+
return out + '…'
|
|
410
|
+
}
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// “进行中”行的迷你动画(2026-10-03 用户需求):三个小符号依次由空心变实心。
|
|
413
|
+
// 帧宽固定 3 列 —— 宽度不变才不会让面板每帧抖动(这也是不用 ⠋⠙⠹ 那种 spinner 的原因:
|
|
414
|
+
// 那个只闪一个符,用户明确要的是“三个点依次点亮”)。
|
|
415
|
+
// 平铺 6 帧:亮→全亮→退回(呼吸感),而不是全亮后硬跳回全空。
|
|
416
|
+
const TODO_ANIM_FRAMES = ['○○○', '●○○', '●●○', '●●●', '●●○', '●○○']
|
|
417
|
+
const TODO_ANIM_MS = 250
|
|
418
|
+
|
|
419
|
+
/** 动画相位(每 tick +1)。模块级 —— 同 agentEndSeen,闭包 flag 在重复注册时不共享。 */
|
|
420
|
+
let animPhase = 0
|
|
421
|
+
let animTimer: ReturnType<typeof setInterval> | undefined
|
|
422
|
+
|
|
423
|
+
/** 启动/保持动画定时器。重复调用不会叠加(已存在则不动)。
|
|
424
|
+
* getTui 是为了每 tick 拿**当前**的 tui 引用 —— widget 可能因刷新被重建,
|
|
425
|
+
* 持旧的引用会调到一个已被丢弃的 renderer。 */
|
|
426
|
+
function startAnimTimer(getTui: () => any): void {
|
|
427
|
+
if (animTimer) return
|
|
428
|
+
animTimer = setInterval(() => {
|
|
429
|
+
animPhase++
|
|
430
|
+
try { getTui()?.requestRender?.() } catch {}
|
|
431
|
+
}, TODO_ANIM_MS)
|
|
432
|
+
// 别阻止进程退出(扩展是宿主进程的一分子,定时器不该吊住事件循环)
|
|
433
|
+
;(animTimer as any)?.unref?.()
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function stopAnimTimer(): void {
|
|
437
|
+
if (animTimer) { clearInterval(animTimer); animTimer = undefined }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** 取当前动画帧(phase 每 tick +1,由定时器驱动)。非进行中的行不传 phase(不显示)。 */
|
|
441
|
+
export function todoAnimFrame(phase: number): string {
|
|
442
|
+
const n = TODO_ANIM_FRAMES.length
|
|
443
|
+
const i = ((Math.floor(phase) % n) + n) % n
|
|
444
|
+
return TODO_ANIM_FRAMES[i]
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export function renderPanelLines(
|
|
448
|
+
data: { total: number; rows: { state: string; id: string; desc: string; note: string }[]; hidden: number },
|
|
449
|
+
who: string,
|
|
450
|
+
width: number,
|
|
451
|
+
fg: (color: string, s: string) => string,
|
|
452
|
+
animPhase = -1,
|
|
453
|
+
nickname = '',
|
|
454
|
+
): string[] {
|
|
455
|
+
if (data.total === 0) return []
|
|
456
|
+
const colorOf = (state: string): string => (state === '滞留中' ? 'muted' : state === '讨论中' ? 'dim' : 'accent')
|
|
457
|
+
const lines: string[] = []
|
|
458
|
+
// 上描边:一条深灰横线,把面板与上方内容分开。
|
|
459
|
+
// 留 1 列余量 —— 终端对“恰好占满宽度”的行有时会折行(各终端行为不一致)。
|
|
460
|
+
lines.push(fg('dim', '─'.repeat(Math.max(0, width - 1))))
|
|
461
|
+
// 昵称附在作者名后(会话内固定,启动时随机抽)。
|
|
462
|
+
const whoPart = who ? who + (nickname ? ` · ${nickname}` : '') : nickname
|
|
463
|
+
const title = whoPart ? `📋 todo (${data.total}) — ${whoPart}` : `📋 todo (${data.total})`
|
|
464
|
+
lines.push(clipToWidth(fg('accent', title), width))
|
|
465
|
+
|
|
466
|
+
const lastIdx = data.rows.length - 1
|
|
467
|
+
data.rows.forEach((t, i) => {
|
|
468
|
+
// `└─` 只给最后一条 —— 不管它有没有断点。
|
|
469
|
+
// (2026-10-03 审查修正:原条件 `i === lastIdx && !t.note` 会让“末条带断点”时
|
|
470
|
+
// 全篇没有 └─ 收尾,断点行的 │ 又延伸到视觉底部,看起来像列表被截断。)
|
|
471
|
+
const isLast = i === lastIdx
|
|
472
|
+
const branch = isLast ? '└─' : '├─'
|
|
473
|
+
// 进行中的行带一个小动画(其余行不加,避免满屏都在跳)
|
|
474
|
+
const anim = t.state === '进行中' && animPhase >= 0 ? ' ' + fg('accent', todoAnimFrame(animPhase)) : ''
|
|
475
|
+
const head = `${fg('dim', branch)} ${fg(colorOf(t.state), `[${t.state}]`)} ${fg('text', t.id)}${anim}`
|
|
476
|
+
const desc = t.desc ? ` ${fg('dim', '—')} ${fg('muted', t.desc)}` : ''
|
|
477
|
+
lines.push(clipToWidth(head + desc, width))
|
|
478
|
+
if (t.note) {
|
|
479
|
+
// 断点行:挂在父任务下。末条时用空格对齐(└─ 下方无续);否则用 │ 延伸。
|
|
480
|
+
const cont = isLast ? ' ' : fg('dim', '│ ')
|
|
481
|
+
lines.push(clipToWidth(`${cont} ${fg('dim', '↳ ' + t.note)}`, width))
|
|
482
|
+
}
|
|
483
|
+
})
|
|
484
|
+
|
|
485
|
+
if (data.hidden > 0) lines.push(clipToWidth(fg('dim', ` +${data.hidden} more`), width))
|
|
486
|
+
return lines
|
|
487
|
+
}
|
|
488
|
+
async function refreshTodoPanel(ui: any, cwd: string): Promise<void> {
|
|
489
|
+
if (String(process.env.ABS_TODO_PANEL || '') === '0') return
|
|
490
|
+
if (!ui || typeof ui.setWidget !== 'function') return
|
|
491
|
+
try {
|
|
492
|
+
if (!(await hasBrain(cwd))) { ui.setWidget(PANEL_KEY, undefined); stopAnimTimer(); return }
|
|
493
|
+
const md = await readFile(join(cwd, '.brain', 'todo.md'), 'utf8')
|
|
494
|
+
const who = await currentUser()
|
|
495
|
+
const data = parseOpenTasks(md, PANEL_MAX_ROWS, who)
|
|
496
|
+
if (data.total === 0) { ui.setWidget(PANEL_KEY, undefined); stopAnimTimer(); return }
|
|
497
|
+
// factory 形式:render(width) 每帧拿真实宽度 → 按宽度截断,不再断字。
|
|
498
|
+
// 动画:phase 存在模块级,render 时读当前值;定时器只在本帧重绘,不重建 widget。
|
|
499
|
+
let tuiRef: any = null
|
|
500
|
+
ui.setWidget(
|
|
501
|
+
PANEL_KEY,
|
|
502
|
+
(tui: any, theme: any) => {
|
|
503
|
+
tuiRef = tui
|
|
504
|
+
return {
|
|
505
|
+
render: (width: number) =>
|
|
506
|
+
renderPanelLines(
|
|
507
|
+
data,
|
|
508
|
+
who,
|
|
509
|
+
width,
|
|
510
|
+
(c: string, s: string) => theme.fg(c, s),
|
|
511
|
+
animPhase,
|
|
512
|
+
sessionNickname,
|
|
513
|
+
),
|
|
514
|
+
invalidate: () => {},
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
{ placement: 'aboveEditor' },
|
|
518
|
+
)
|
|
519
|
+
// 只在真有“进行中”任务时跑定时器 —— 否则白刷 CPU(无动画可播)。
|
|
520
|
+
const needAnim = data.rows.some((t) => t.state === '进行中')
|
|
521
|
+
if (needAnim) startAnimTimer(() => tuiRef)
|
|
522
|
+
else stopAnimTimer()
|
|
523
|
+
} catch {
|
|
524
|
+
try { ui.setWidget(PANEL_KEY, undefined) } catch {}
|
|
525
|
+
stopAnimTimer()
|
|
526
|
+
}
|
|
64
527
|
}
|
|
65
528
|
|
|
66
529
|
export default function absPiHook(pi: ExtensionAPI): void {
|
|
67
530
|
// 埋点状态同上,故意声明在**模块级**(不在被反复调用的工厂函数里)。
|
|
68
531
|
resetThrottle()
|
|
69
532
|
|
|
70
|
-
pi.on("session_start", (event: any,
|
|
533
|
+
pi.on("session_start", (event: any, ctx: any) => {
|
|
71
534
|
resetThrottle()
|
|
535
|
+
// 本会话的随机昵称 —— 在这里抽一次(不是每帧抽,否则面板会疯狂闪)。
|
|
536
|
+
// 每次 session_start(startup/reload/new/resume/fork)重抽 → “每次打开 pi 都是随机的”。
|
|
537
|
+
sessionNickname = pickNickname()
|
|
538
|
+
logHook(`session_start nickname=${sessionNickname || '(off)'}`).catch(() => {})
|
|
539
|
+
// 启动/重载时就画出面板(reason=startup|reload|new|resume|fork)。
|
|
540
|
+
// 时序:pi 在 session_start 时已给 ctx.ui(无 UI 时是 noOp,调了不报错),
|
|
541
|
+
// 但设 setWidget 需真的 TUI —— 故用 hasUI 挡一下并留痕,便于判定“没显示”的原因。
|
|
542
|
+
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
543
|
+
if (ctx && ctx.hasUI) {
|
|
544
|
+
refreshTodoPanel(ctx.ui, cwd)
|
|
545
|
+
.then(() => logHook(`session_start panel drawn reason=${event?.reason}`).catch(() => {}))
|
|
546
|
+
.catch(() => logHook(`session_start panel error reason=${event?.reason}`).catch(() => {}))
|
|
547
|
+
} else {
|
|
548
|
+
logHook(`session_start panel skipped no_ui reason=${event?.reason}`).catch(() => {})
|
|
549
|
+
}
|
|
72
550
|
return logHook("session_start").catch(() => {})
|
|
73
551
|
})
|
|
74
552
|
|
|
553
|
+
// 面板实时刷新(两事件配合)—— 为何不能只用其中一个:
|
|
554
|
+
// 实测(2026-10-03)MCP 工具在 tool_execution_end 里的 toolName 是代理入口名
|
|
555
|
+
// `mcp__abs`,**不是子工具名**,且该事件不带 args → 无法从它分辨是不是 abs_task。
|
|
556
|
+
// 而 tool_call 带 input,能从里面认出目标工具,但它在**执行前**触发(此刻 todo.md 还没变)。
|
|
557
|
+
// 故:tool_call 认出“刚才调的是 todo 工具”→ 置标记;tool_execution_end 看到标记就刷 → 清标记。
|
|
558
|
+
// 始终保留 turn_end 兜底(实测每条 assistant 消息都触发,约 4 秒一次)。
|
|
559
|
+
// 标记在模块级(重复注册时闭包 flag 不共享 —— 见 agentEndSeen 的注解)。
|
|
560
|
+
pi.on("tool_call", (event: any) => {
|
|
561
|
+
const name = String(event?.toolName || '')
|
|
562
|
+
const input = event?.input || {}
|
|
563
|
+
// 两种形态:① MCP 代理入口 mcp__abs + input.tool=abs_task;② 直接工具名 abs_task
|
|
564
|
+
const target = String(input?.tool ?? input?.name ?? '')
|
|
565
|
+
if (/abs_(task|board)/.test(name) || /abs_(task|board)/.test(target)) {
|
|
566
|
+
pendingTodoTool = true
|
|
567
|
+
logHook(`tool_call todo_tool name=${name} target=${target || '-'}`).catch(() => {})
|
|
568
|
+
}
|
|
569
|
+
})
|
|
570
|
+
|
|
571
|
+
pi.on("tool_execution_end", (_event: any, ctx: any) => {
|
|
572
|
+
if (!pendingTodoTool) return
|
|
573
|
+
pendingTodoTool = false
|
|
574
|
+
logHook(`tool_execution_end panel refresh after todo tool`).catch(() => {})
|
|
575
|
+
refreshTodoPanel(ctx?.ui, (ctx && ctx.cwd) || process.cwd()).catch(() => {})
|
|
576
|
+
})
|
|
577
|
+
|
|
578
|
+
pi.on("turn_end", (_event: any, ctx: any) => {
|
|
579
|
+
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
580
|
+
logHook(`turn_end panel refresh cwd=${cwd}`).catch(() => {})
|
|
581
|
+
refreshTodoPanel(ctx?.ui, cwd).catch(() => {})
|
|
582
|
+
})
|
|
583
|
+
|
|
584
|
+
// todo 及时性验证(2026-10-03):往 system prompt 的 Guidelines 段追加静态条目。
|
|
585
|
+
// 不新增消息、不抢 turn、不产生对话条目 —— 与删掉的两次注入做法本质不同(见文件头)。
|
|
586
|
+
// 留一行日志:否则「有没有生效」只能凭感觉,无法验证。
|
|
587
|
+
pi.on("before_agent_start", (event: any, ctx: any) => {
|
|
588
|
+
const cwd = (ctx && ctx.cwd) || process.cwd()
|
|
589
|
+
// 异步 handler:pi 会 await(emitBeforeAgentStart 是 await 的),故可以查盘。
|
|
590
|
+
return (async () => {
|
|
591
|
+
try {
|
|
592
|
+
if (!(await hasBrain(cwd))) {
|
|
593
|
+
logHook(`before_agent_start todo_guide=off reason=no_brain`).catch(() => {})
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
const ok = injectTodoGuidelines(event?.systemPromptOptions)
|
|
597
|
+
logHook(`before_agent_start todo_guide=${ok ? 'on' : 'off'} cwd=${cwd}`).catch(() => {})
|
|
598
|
+
} catch (e: any) {
|
|
599
|
+
logHook(`before_agent_start error=${e?.message || 'unknown'}`).catch(() => {})
|
|
600
|
+
}
|
|
601
|
+
})()
|
|
602
|
+
})
|
|
603
|
+
|
|
75
604
|
// 收尾注入已停用(2026-09-18):sendUserMessage(deliverAs:"followUp") 会在每轮
|
|
76
605
|
// agent_end 抢一个 follow-up turn,用户实报「每次干活干一会出来, 任务就中断了」。
|
|
77
606
|
//
|
|
@@ -88,6 +617,7 @@ export default function absPiHook(pi: ExtensionAPI): void {
|
|
|
88
617
|
})
|
|
89
618
|
|
|
90
619
|
pi.on("session_shutdown", (_e: any, ctx: any) => {
|
|
620
|
+
stopAnimTimer() // 面板没了,动画定时器必须一起停(否则残留定时器白刷已丢弃的 tui)
|
|
91
621
|
snapshotWrapup((ctx && ctx.cwd) || process.cwd())
|
|
92
622
|
logHook("session_shutdown").catch(() => {})
|
|
93
623
|
})
|
package/package.json
CHANGED
package/src/store.js
CHANGED
|
@@ -303,8 +303,9 @@ export async function cmdLoad({ dir }) {
|
|
|
303
303
|
// 未设姓名/姓名是占位名时开场就提醒 —— load 是开机第一屏,不在这里提,
|
|
304
304
|
// 用户要撞到第一次写操作才知道(init/load 一路沉默)。
|
|
305
305
|
// 占位名(历史遗留 tester/foo)必须提:配置里有值 ≠ 名字是对的。
|
|
306
|
+
// 一次调用拿两者(placeholderWarn 内部也要 getUser)—— 原先分两次读盘(2026-10-03 审查)。
|
|
306
307
|
const who = await getUser();
|
|
307
|
-
const ph = await placeholderWarn();
|
|
308
|
+
const ph = who ? await placeholderWarn() : null;
|
|
308
309
|
if (!who) {
|
|
309
310
|
sections.push(
|
|
310
311
|
'⚠ 尚未设置使用者姓名(写操作会先报错)',
|
package/src/userconfig.js
CHANGED
|
@@ -49,8 +49,10 @@ function assertRealName(name) {
|
|
|
49
49
|
' 请填你本人的名字(如 abs config set user 张三 / alice)'
|
|
50
50
|
);
|
|
51
51
|
}
|
|
52
|
-
// 无意义重复串:aaa/xxx/111
|
|
53
|
-
|
|
52
|
+
// 无意义重复串:aaa/xxx/111 之类。
|
|
53
|
+
// 长度门槛 ≥3 —— `oo`/`ee`/`ww` 这种两字母是合法的姓名缩写(2026-10-03 审查发现
|
|
54
|
+
// 原规则会误伤它们),也避免中文叠字小名(如 `中中`)被无故拒绝。
|
|
55
|
+
if (lower.length >= 3 && /^(.)\1+$/.test(lower)) {
|
|
54
56
|
throw new Error(`✗ "${name}" 看起来不是名字(重复字符)—— 请填你本人的名字`);
|
|
55
57
|
}
|
|
56
58
|
return name;
|
|
@@ -99,7 +101,8 @@ export async function placeholderWarn() {
|
|
|
99
101
|
const u = await getUser();
|
|
100
102
|
if (!u) return null;
|
|
101
103
|
const lower = u.toLowerCase();
|
|
102
|
-
|
|
104
|
+
// 与 assertRealName 同一规则(含长度门槛 ≥3),两处不能写成不同判据
|
|
105
|
+
if (PLACEHOLDER_NAMES.has(lower) || (lower.length >= 3 && /^(.)\1+$/.test(lower))) return u;
|
|
103
106
|
return null;
|
|
104
107
|
}
|
|
105
108
|
|