@bachi/pi-coder 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 (101) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +162 -0
  4. package/config/AGENTS.md +100 -0
  5. package/config/pi-statusline.json +140 -0
  6. package/config/settings.json +38 -0
  7. package/config/web-search.json +5 -0
  8. package/docs/README.md +14 -0
  9. package/docs/configuration.md +123 -0
  10. package/docs/development.md +177 -0
  11. package/docs/extensions.md +292 -0
  12. package/docs/handbook.zh.md +432 -0
  13. package/docs/installation.md +124 -0
  14. package/docs/themes.md +107 -0
  15. package/extensions/ask-user-question/answers.test.ts +104 -0
  16. package/extensions/ask-user-question/answers.ts +72 -0
  17. package/extensions/ask-user-question/dialog.test.ts +180 -0
  18. package/extensions/ask-user-question/dialog.ts +102 -0
  19. package/extensions/ask-user-question/index.ts +253 -0
  20. package/extensions/ask-user-question/model.test.ts +275 -0
  21. package/extensions/ask-user-question/model.ts +259 -0
  22. package/extensions/ask-user-question/schema.ts +49 -0
  23. package/extensions/ask-user-question/types.ts +86 -0
  24. package/extensions/ask-user-question/validate.test.ts +183 -0
  25. package/extensions/ask-user-question/validate.ts +110 -0
  26. package/extensions/ask-user-question/view.ts +262 -0
  27. package/extensions/auto-default-model/default-model.test.ts +268 -0
  28. package/extensions/auto-default-model/index.ts +87 -0
  29. package/extensions/bash-command-collapse.ts +1476 -0
  30. package/extensions/below-editor-after-statusline.ts +118 -0
  31. package/extensions/clear-command.ts +29 -0
  32. package/extensions/cwd-statusline.ts +39 -0
  33. package/extensions/exit-command.ts +59 -0
  34. package/extensions/fenceless-code-block/index.test.ts +208 -0
  35. package/extensions/fenceless-code-block/index.ts +28 -0
  36. package/extensions/fenceless-code-block/render.test.ts +177 -0
  37. package/extensions/fenceless-code-block/render.ts +142 -0
  38. package/extensions/folder-history.ts +197 -0
  39. package/extensions/init-command.ts +163 -0
  40. package/extensions/prompt-editor/bash-prompt.test.ts +94 -0
  41. package/extensions/prompt-editor/bash-prompt.ts +59 -0
  42. package/extensions/prompt-editor/render.test.ts +283 -0
  43. package/extensions/prompt-editor.ts +212 -0
  44. package/extensions/read-path-collapse.ts +474 -0
  45. package/extensions/recap/index.test.ts +348 -0
  46. package/extensions/recap/index.ts +462 -0
  47. package/extensions/recap/subagents.test.ts +144 -0
  48. package/extensions/recap/subagents.ts +128 -0
  49. package/extensions/rewind/README.md +229 -0
  50. package/extensions/rewind/checkpoints.test.ts +560 -0
  51. package/extensions/rewind/checkpoints.ts +820 -0
  52. package/extensions/rewind/flow.test.ts +756 -0
  53. package/extensions/rewind/flow.ts +362 -0
  54. package/extensions/rewind/index.ts +400 -0
  55. package/extensions/rewind/picker.ts +135 -0
  56. package/extensions/rewind/viewport.test.ts +76 -0
  57. package/extensions/rewind/viewport.ts +48 -0
  58. package/extensions/simple-task/gap.test.ts +147 -0
  59. package/extensions/simple-task/gap.ts +122 -0
  60. package/extensions/simple-task/index.ts +439 -0
  61. package/extensions/simple-task/types.ts +53 -0
  62. package/extensions/simple-task/widget.ts +86 -0
  63. package/extensions/startup-logo/header-guard.test.ts +274 -0
  64. package/extensions/startup-logo/header-guard.ts +166 -0
  65. package/extensions/startup-logo/index.test.ts +305 -0
  66. package/extensions/startup-logo/index.ts +194 -0
  67. package/extensions/startup-logo/loaded-sections.test.ts +257 -0
  68. package/extensions/startup-logo/loaded-sections.ts +267 -0
  69. package/extensions/startup-logo/logo.test.ts +124 -0
  70. package/extensions/startup-logo/logo.ts +124 -0
  71. package/extensions/statusline/footer-guard.test.ts +273 -0
  72. package/extensions/statusline/footer-guard.ts +171 -0
  73. package/extensions/statusline/git.test.ts +174 -0
  74. package/extensions/statusline/git.ts +142 -0
  75. package/extensions/statusline/index.ts +294 -0
  76. package/extensions/statusline/line.test.ts +316 -0
  77. package/extensions/statusline/line.ts +201 -0
  78. package/extensions/subagent-log-guard/filter.test.ts +85 -0
  79. package/extensions/subagent-log-guard/filter.ts +32 -0
  80. package/extensions/subagent-log-guard/index.ts +112 -0
  81. package/extensions/theme-command.ts +263 -0
  82. package/extensions/thinking-collapse/window.test.ts +321 -0
  83. package/extensions/thinking-collapse/window.ts +354 -0
  84. package/extensions/thinking-collapse.ts +60 -0
  85. package/extensions/tool-diff/title-row.test.ts +254 -0
  86. package/extensions/tool-diff/title-row.ts +191 -0
  87. package/extensions/tool-diff.ts +1276 -0
  88. package/extensions/working-indicator/bash-spinner.test.ts +135 -0
  89. package/extensions/working-indicator/bash-spinner.ts +114 -0
  90. package/extensions/working-indicator/index.test.ts +579 -0
  91. package/extensions/working-indicator/index.ts +940 -0
  92. package/extensions/working-indicator/spinner-frames.test.ts +219 -0
  93. package/extensions/working-indicator/spinner-frames.ts +156 -0
  94. package/extensions/working-indicator/summary-request.test.ts +195 -0
  95. package/extensions/working-indicator/summary-request.ts +207 -0
  96. package/extensions/working-indicator/working-summary.test.ts +499 -0
  97. package/extensions/working-indicator/working-summary.ts +375 -0
  98. package/package.json +71 -0
  99. package/themes/ayu.json +97 -0
  100. package/themes/catppuccin.json +103 -0
  101. package/themes/summer-night.json +87 -0
@@ -0,0 +1,462 @@
1
+ /**
2
+ * recap — 极简会话摘要,取代第三方包 `@fradser/pi-recap`。
3
+ *
4
+ * 功能只有两条,刻意做到最小:
5
+ * 1. `/recap` —— 手动总结当前对话;
6
+ * 2. 对话结束后**静止 30 秒**(没有任何新输入)自动生成一条摘要,显示在输入框上方。
7
+ * 发出新消息后摘要立即消失(它已经是上一轮的过期提醒了)。
8
+ *
9
+ * **有子代理在跑时不算「结束」**:静止 30s 只是「主回合结束了」的信号,而异步子代理
10
+ * (`subagent({ async: true })`)是脱离回合的 —— 主回合早早 settled,子代理还在后台跑,
11
+ * 30s 到点就会把「刚把任务发出去、还在等」总结成「这轮干完了」。所以计时器到点先问一句
12
+ * 「还有子代理在跑吗」(pi-subagents 的进程内 RPC,见 `subagents.ts`):有就只重查、
13
+ * 不生成,等它们都结束了再重新起一轮 30s 定时。同一个回合还在跑时(`ctx.isIdle()`
14
+ * 为 false,例如被异步子代理的完成通知唤醒的新回合)同样不生成。
15
+ *
16
+ * 为什么要自己写而不是用 pi-recap:
17
+ * pi-recap 的触发时机是**回合制** —— 监听 `agent_settled`,回合一结束就立刻生成并显示,
18
+ * 于是它每轮都刷新、一直挂在输入框上方。而 recap 的用途是**提醒**:开发者离开窗口再回来时,
19
+ * 一眼看到这个会话在做什么。那需要的是**闲置制**,不是回合制。我核过 pi-recap
20
+ * 0.1.1~0.1.7 全部七个版本:每个都是 `pi.on("agent_settled")` 直接调 `performRecap()`,
21
+ * 没有任何 idle / setTimeout / debounce 机制(唯一的 `setInterval` 是 spinner 转帧动画),
22
+ * 它自己的 BDD 契约也写明是 "When an agent turn settles" —— 闲置语义它从来没有过。
23
+ * 另外它带一堆用不上的东西:模型选择菜单、语言选择、`recap.json` 配置、session 落盘、
24
+ * 跨会话注册表同步。本扩展把这些全部去掉。
25
+ *
26
+ * 刻意不做的事(都是明确要求,别"顺手补上"):
27
+ * - **不做任何本地存储**:不写 `recap.json`、不写 `~/.pi/agent/directory-sessions/`。
28
+ * - **不落 session、不进上下文**:不调 `pi.appendEntry`。摘要只活在内存里(`currentRecap`),
29
+ * 所以它既不会出现在会话日志里,也不会被后续请求带进上下文。生成走的是一次独立的
30
+ * `modelRegistry.complete()` 调用,与主对话的请求互不相干。
31
+ * 代价:`/new` 或 `/resume` 后摘要不会恢复(这是刻意的,不是 bug)。
32
+ * - **不做任何配置**:闲置阈值写死 30s,语言写死中文,不提供环境变量、不提供开关命令。
33
+ * - 不 import `@fradser/pi-recap` 的任何文件(虽然它把 `generateRecap` / `buildRecapPrompt` /
34
+ * `getLastExchange` 都导出了,复用能少写约 100 行,但那会把本扩展绑死在第三方包的内部
35
+ * 文件布局上 —— 它一升级或一卸载本扩展就崩)。提示词、清洗、取最后一轮对话全部自己实现。
36
+ *
37
+ * 显示格式与 pi-recap 一致:` ✦ Recap: <摘要>`,✦ 用 accent 色、"Recap:" 用 dim 色,
38
+ * 续行按前缀宽度缩进对齐。**额外要求:摘要下方补一个空行**(render 末尾 push(""))。
39
+ * 上方挨着别的 widget 时(`simple-task/` 的任务清单、pi-subagents 的 `async subagent`
40
+ * 块、任何第三方扩展)**摘要上方再补一个空行** —— 判定直接复用 `simple-task/gap.ts`
41
+ * 的「渲染邻居、看它面向自己那一侧有没有内容」,而不是按任务清单状态猜(见 `showWidget`
42
+ * 里那段 `widgetGaps` 与重入保护的说明)。import 兄弟扩展的文件是刻意的取舍:两个扩展
43
+ * 同仓库、同目录树、一起安装,依赖不存在的场景不存在,省掉一份重复的 walk 逻辑。
44
+ *
45
+ * 生命周期:
46
+ * `agent_start` → 取消计时器(新一轮开始,上一轮排的摘要已经过期)
47
+ * `agent_settled` → 起 30s 计时器(每轮结束都重置)
48
+ * 计时器到点(空闲) → 没有子代理在跑?→ 生成摘要 → 显示 widget
49
+ * 还有子代理在跑 → 不生成,改 10s 一次重查
50
+ * 计时器到点(重查) → 还在跑 → 继续重查;都没了 → 重新起 30s 定时
51
+ * `input`(交互输入) → 取消计时器 + abort 生成 + 清 widget
52
+ * `session_start` → 清内存状态与 widget(新会话/恢复会话都不该带着上一会话的摘要)
53
+ * `session_shutdown` → 停表 + abort
54
+ *
55
+ * 子代理探测放在 `subagents.ts`(不 import pi,可 `node --test` 单测):问 pi-subagents
56
+ * 的进程内 RPC;对方不在 / 超时 / 回包不认识一律当「没有」(fail-open)—— 不能因为探测
57
+ * 环节把 recap 整个弄停摆。
58
+ *
59
+ * 必须防的坑(`simple-task/` 踩过的同一个):
60
+ * **捕获的 `ctx` 在会话结束后会 stale**,访问 `ctx.ui` 会抛
61
+ * `"This extension ctx is stale after session replacement or reload"`。30s 计时器会比会话
62
+ * 活得久,抛出发生在读 `ctx.ui` 那一刻、比 widget 的 `render()` 更早,所以 render 内部的
63
+ * try/catch 拦不住 —— 一个活过会话的定时器会**直接把宿主进程带崩**。因此三层防护:
64
+ * 所有 `ctx.ui` 访问都包 try/catch、`session_shutdown` 里立刻停表 + abort、计时器 `unref()`。
65
+ * 生成期间用户发新消息也要 abort,否则旧一轮的摘要会覆盖新一轮的。
66
+ */
67
+
68
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
69
+ import { visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
70
+ import { hasActiveSubagentWork } from "./subagents.ts";
71
+ import { widgetGaps } from "../simple-task/gap.ts";
72
+
73
+ /** 对话结束后静止多久才生成摘要。写死,不做配置。 */
74
+ const IDLE_MS = 30_000;
75
+ /** 还有子代理在跑时的重查间隔(只重查、不生成)。 */
76
+ const SUBAGENT_WAIT_MS = 10_000;
77
+ /** 生成的硬超时,到点 abort。 */
78
+ const GENERATE_TIMEOUT_MS = 30_000;
79
+ /** 摘要上限:单行、120 字符以内("一眼可扫")。 */
80
+ const MAX_RECAP_CHARS = 120;
81
+ /** 摘要模型的最大输出 token。 */
82
+ const MAX_TOKENS = 96;
83
+ /** 喂给摘要模型的对话片段上限,避免把整段长回复塞进提示词。 */
84
+ const EXCHANGE_CLIP_CHARS = 4000;
85
+ /** widget key。pi-recap 已卸载,所以直接用 "recap"。 */
86
+ const WIDGET_KEY = "recap";
87
+
88
+ export default function (pi: ExtensionAPI) {
89
+ /** 摘要只活在内存里 —— 不落盘、不进上下文。 */
90
+ let currentRecap = "";
91
+ /** 已生成过的回合指纹,用于去重(同一轮不重复调模型)。 */
92
+ let completedKey: string | undefined;
93
+ let timer: ReturnType<typeof setTimeout> | undefined;
94
+ let abortController: AbortController | undefined;
95
+ /**
96
+ * 每次 `cancel()` 递增。计时器回调是异步的(要等一次子代理探测,最多 1s),
97
+ * 探测期间用户可能发新消息、新一轮可能开始 —— 回调靠这个序号判断自己是否已经过期。
98
+ */
99
+ let stateToken = 0;
100
+
101
+ // ─── 计时器 ────────────────────────────────────────────────
102
+
103
+ function cancel(): void {
104
+ stateToken += 1;
105
+ if (timer) {
106
+ clearTimeout(timer);
107
+ timer = undefined;
108
+ }
109
+ if (abortController) {
110
+ abortController.abort();
111
+ abortController = undefined;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * 起一枚计时器。两种模式:
117
+ * `idle` —— 空闲倒计时,到点就生成摘要(除非发现还有活)
118
+ * `waiting` —— 明知有子代理在跑,到点只重新查一次(不生成)
119
+ * 两者共用同一枚 `timer`(`schedule` 先 `cancel`),所以永远不会同时跑两枚。
120
+ */
121
+ function schedule(ctx: ExtensionContext, mode: "idle" | "waiting"): void {
122
+ cancel();
123
+ const token = stateToken;
124
+ timer = setTimeout(() => {
125
+ timer = undefined;
126
+ // 计时器会比会话活得久,ctx 可能已经 stale —— 整个回调都要兜住。
127
+ void onTimerFire(ctx, mode, token).catch(() => {});
128
+ }, mode === "idle" ? IDLE_MS : SUBAGENT_WAIT_MS);
129
+ // 别让一个待执行的摘要把进程吊住(headless / 退出场景)。
130
+ timer.unref?.();
131
+ }
132
+
133
+ /**
134
+ * 计时器到点:先确认「真的没事干了」,再决定生成、继续等、还是重新起表。
135
+ *
136
+ * - 还有活(回合在跑 / 有子代理在跑) → 转 `waiting` 重查(不生成)
137
+ * - `waiting` 重查到没活了 → 转 `idle` 重新起 30s 定时
138
+ * —— 子代理结束的那一刻不生成摘要:结果刚回来、被唤醒的回合正要跑,
139
+ * 这时生成的摘要必然是半截的;让正常的回合结束 → 静止 30s 流程接管。
140
+ * - `idle` 重查到没活了 → 生成摘要
141
+ *
142
+ * 探测是一次事件总线上的一问一答(最多 1s),期间用户可能发新消息、新一轮可能开始,
143
+ * 所以拿 `token` 在 `await` 之后重申一次:过期的回调直接退场(见 `stateToken`)。
144
+ */
145
+ async function onTimerFire(ctx: ExtensionContext, mode: "idle" | "waiting", token: number): Promise<void> {
146
+ const busy = await workInProgress(ctx);
147
+ // 探测期间被取消(用户发了新消息 / 新一轮开始 / 换了会话):这枚计时器已经过期。
148
+ if (token !== stateToken) return;
149
+ if (busy === undefined) return;
150
+ if (busy) {
151
+ schedule(ctx, "waiting");
152
+ return;
153
+ }
154
+ if (mode === "waiting") {
155
+ schedule(ctx, "idle");
156
+ return;
157
+ }
158
+ await generate(ctx);
159
+ }
160
+
161
+ /**
162
+ * 现在还有活在跑吗。`undefined` = 已经问不出(ctx stale,会话被换掉了)——
163
+ * 这时什么都不做,等 `session_start` / `session_shutdown` 来清场。
164
+ *
165
+ * `ctx.isIdle()` 连着 `isCompacting`(pi 的实现是 `!streaming && !compacting`),
166
+ * 所以压缩期间也算「有活」。它可能抛 stale ctx 异常,必须包住。
167
+ */
168
+ async function workInProgress(ctx: ExtensionContext): Promise<boolean | undefined> {
169
+ try {
170
+ if (!ctx.isIdle()) return true;
171
+ } catch {
172
+ return undefined;
173
+ }
174
+ try {
175
+ return await hasActiveSubagentWork(pi.events);
176
+ } catch {
177
+ // 探测本身能自包异常,这里是第二层保险:拿不到答案就当作没活。
178
+ return false;
179
+ }
180
+ }
181
+
182
+ // ─── widget ────────────────────────────────────────────────
183
+
184
+ function clearWidget(ctx: ExtensionContext): void {
185
+ try {
186
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
187
+ } catch {
188
+ // stale ctx:会话已被替换,什么都不做。
189
+ }
190
+ }
191
+
192
+ function showWidget(ctx: ExtensionContext): void {
193
+ try {
194
+ if (!currentRecap) {
195
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
196
+ return;
197
+ }
198
+ const text = currentRecap;
199
+ ctx.ui.setWidget(WIDGET_KEY, (tui, theme) => {
200
+ /**
201
+ * 重入保护。`gap.ts` 的「看邻居」是**把邻居渲染出来**实现的,而 simple-task
202
+ * 那边的 gap.ts 也会反过来渲染 recap —— 两侧都走 walk 就会互递归(无保护时
203
+ * 递归到自己撑不住为止,靠各处的 try/catch 兜住就退化成"猜",间隔不再可靠)。
204
+ * 所以本组件被重入时只输出内容行、跳过探测:外层那次 walk 随后看到的是对方
205
+ * **最终**的渲染结果,于是双方各自补一次、合起来恰好一行。
206
+ *
207
+ * 这个保护**不能挪进 `gap.ts`**:那里的「重入」意味着嵌套的这次 walk 一律返回
208
+ * 「无间隔」,于是 simple-task 会按"邻居首行有内容"补一次行尾空行、recap 再补
209
+ * 一次前导空行 —— 变成两行空行。粒度必须落在每个走 walk 的组件上。
210
+ */
211
+ let inspectingNeighbours = false;
212
+ const component = {
213
+ render(width: number): string[] {
214
+ // 格式对齐 pi-recap:` ✦ Recap: <摘要>`,✦ 用 accent、"Recap:" 用 dim。
215
+ const prefix = ` ${theme.fg("accent", "✦")} ${theme.fg("dim", "Recap:")} `;
216
+ const prefixWidth = visibleWidth(prefix);
217
+ const contentWidth = Math.max(15, (width || 80) - prefixWidth);
218
+ const indent = " ".repeat(prefixWidth);
219
+ const wrapped = wrapTextWithAnsi(theme.fg("text", text), contentWidth);
220
+ const lines = wrapped.map((line, i) => (i === 0 ? prefix + line : indent + line));
221
+ // 上方挨着别的 widget(任务清单 / `async subagent` 块…)就补一行前导空行。
222
+ // 判据与 simple-task 共用(`gap.ts` 文件头:邻居面向自己那一侧有可见内容、
223
+ // 且不是空行 → 补)。pi 按 Map 插入顺序渲染编辑器上方的容器,recap 永远
224
+ // 最后注册(闲置 30s 后才注册),所以上方的邻居才是常态。
225
+ let gapAbove = false;
226
+ if (!inspectingNeighbours) {
227
+ inspectingNeighbours = true;
228
+ try {
229
+ gapAbove = widgetGaps(tui, component, width).above;
230
+ } catch {
231
+ // walk 出意外(pi 换了内部结构):宁可不补空行,也不让异常崩掉这一帧。
232
+ gapAbove = false;
233
+ } finally {
234
+ inspectingNeighbours = false;
235
+ }
236
+ }
237
+ if (gapAbove) lines.unshift("");
238
+ // 额外要求:摘要下方补一个空行。
239
+ lines.push("");
240
+ return lines;
241
+ },
242
+ invalidate() {},
243
+ };
244
+ return component;
245
+ });
246
+ } catch {
247
+ // stale ctx。
248
+ }
249
+ }
250
+
251
+ // ─── 生成 ──────────────────────────────────────────────────
252
+
253
+ async function generate(ctx: ExtensionContext, force = false): Promise<void> {
254
+ if (ctx.mode !== "tui") return;
255
+
256
+ const model = ctx.model;
257
+ if (!model) return;
258
+
259
+ const branch = (ctx.sessionManager?.getBranch?.() ?? []) as unknown[];
260
+ const exchange = getLastExchange(branch);
261
+ if (!exchange) return;
262
+
263
+ const key = [exchange.user, exchange.assistant, model.provider, model.id].join("\u0000");
264
+ if (!force && completedKey === key && currentRecap) return;
265
+
266
+ const controller = new AbortController();
267
+ abortController = controller;
268
+ // 用局部 const 而不是模块级的 `abortController`:`cancel()` 会把它置空,
269
+ // 事后再读 `abortController?.signal.aborted` 就成了 undefined(守卫失效,
270
+ // 被中断的那次生成反而会覆盖掉更新的摘要)—— 局部引用永远指向本次的 controller。
271
+ const timeout = setTimeout(() => controller.abort(), GENERATE_TIMEOUT_MS);
272
+
273
+ try {
274
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
275
+ if (!auth.ok) return;
276
+
277
+ const response = await ctx.modelRegistry.complete(
278
+ model,
279
+ {
280
+ systemPrompt: "You generate ultra-concise, single-line session recaps.",
281
+ messages: [
282
+ {
283
+ role: "user",
284
+ content: [{ type: "text", text: buildPrompt(exchange, currentRecap || undefined) }],
285
+ timestamp: Date.now(),
286
+ },
287
+ ],
288
+ },
289
+ {
290
+ apiKey: auth.apiKey,
291
+ headers: auth.headers,
292
+ signal: controller.signal,
293
+ maxTokens: MAX_TOKENS,
294
+ temperature: 0,
295
+ cacheRetention: "none",
296
+ },
297
+ );
298
+
299
+ // 被中断 / 被新一轮取代:丢弃结果,绝不覆盖更新的摘要。
300
+ if (controller.signal.aborted) return;
301
+
302
+ const text = cleanRecapText(textFromAssistant(response));
303
+ if (!text) return;
304
+
305
+ completedKey = key;
306
+ currentRecap = text;
307
+ showWidget(ctx);
308
+ } catch {
309
+ // 超时 / 中断 / 网络错误:静默放弃,不打扰用户。
310
+ } finally {
311
+ clearTimeout(timeout);
312
+ if (abortController === controller) abortController = undefined;
313
+ }
314
+ }
315
+
316
+ // ─── 事件 ──────────────────────────────────────────────────
317
+
318
+ // agent_settled = "pi 不会再自动继续跑"(文档原文:no retry/compaction/follow-up left),
319
+ // 是判定"对话结束"最准确的事件。agent_end 不行:那之后 pi 仍可能自动重试、压缩重试、
320
+ // 或继续跑排队中的 follow-up 消息。
321
+ // 注意:settled 只说明**主回合**结束了。异步子代理可能还在后台跑,那时待执行的摘要
322
+ // 会被 `onTimerFire` 拦下来转成重查(见那里),所以这里无条件起表是对的。
323
+ pi.on("agent_settled", (_event, ctx) => {
324
+ if (ctx.mode !== "tui") return;
325
+ schedule(ctx, "idle");
326
+ });
327
+
328
+ // 新一轮开始 = 又进入「任务进行中」:取消上一轮排的摘要(回合结束时 settled 会重新起表)。
329
+ // 被异步子代理的完成通知唤醒的回合也会走到这里 —— 那时绝不能把子代理还在跑时的
330
+ // 半截状态总结出来。已经显示在屏幕上的摘要不动(只清待执行的计时器)。
331
+ pi.on("agent_start", () => {
332
+ cancel();
333
+ });
334
+
335
+ // 发出新消息:取消待执行的生成,并清掉屏幕上那条(此时它已经是上一轮的过期提醒)。
336
+ // 只认交互输入(rpc / extension 来源不算"用户继续对话")。
337
+ pi.on("input", (event, ctx) => {
338
+ if (event.source !== "interactive") return;
339
+ cancel();
340
+ currentRecap = "";
341
+ completedKey = undefined;
342
+ clearWidget(ctx);
343
+ });
344
+
345
+ // 新会话 / 恢复会话:摘要不落盘,所以什么都不该带过来。
346
+ pi.on("session_start", (_event, ctx) => {
347
+ cancel();
348
+ currentRecap = "";
349
+ completedKey = undefined;
350
+ clearWidget(ctx);
351
+ });
352
+
353
+ // 关键:停表 + abort,否则定时器活过会话后访问 stale ctx 会把宿主进程带崩。
354
+ pi.on("session_shutdown", () => {
355
+ cancel();
356
+ });
357
+
358
+ // ─── 命令 ──────────────────────────────────────────────────
359
+
360
+ pi.registerCommand("recap", {
361
+ description: "总结当前对话",
362
+ handler: async (_args, ctx) => {
363
+ cancel();
364
+ currentRecap = "";
365
+ completedKey = undefined;
366
+ // 手动触发不走「有没有子代理在跑」的闸门:用户现在就要,不管后台在跑什么。
367
+ await generate(ctx, true);
368
+ ctx.ui.notify(currentRecap ? `✦ Recap: ${currentRecap}` : "没能生成 recap(无可用对话或模型返回为空)", "info");
369
+ },
370
+ });
371
+
372
+ // ─── 纯函数(自包含,不依赖任何第三方包)──────────────────────
373
+
374
+ /** 从消息内容(字符串或 content-block 数组)里抽纯文本。 */
375
+ function textFromContent(content: unknown): string {
376
+ if (typeof content === "string") return content.trim();
377
+ if (!Array.isArray(content)) return "";
378
+ return content
379
+ .filter((b: any) => b?.type === "text" && typeof b.text === "string")
380
+ .map((b: any) => b.text as string)
381
+ .join("\n")
382
+ .trim();
383
+ }
384
+
385
+ /** 从 assistant 响应里抽文本(thinking-only 的输出会得到空串,于是放弃这次生成)。 */
386
+ function textFromAssistant(message: unknown): string {
387
+ return textFromContent((message as any)?.content);
388
+ }
389
+
390
+ /** 取最近一轮 user / assistant 配对。倒序扫,两个都拿到就停。 */
391
+ function getLastExchange(entries: unknown[]): { user: string; assistant: string } | undefined {
392
+ let lastUser: string | undefined;
393
+ let lastAssistant: string | undefined;
394
+
395
+ for (let i = entries.length - 1; i >= 0; i--) {
396
+ const entry = entries[i] as any;
397
+ if (entry?.type !== "message") continue;
398
+ const msg = entry.message;
399
+ if (!msg) continue;
400
+
401
+ if (msg.role === "assistant" && !lastAssistant) {
402
+ const text = textFromContent(msg.content);
403
+ if (text) lastAssistant = text;
404
+ } else if (msg.role === "user" && !lastUser) {
405
+ const text = textFromContent(msg.content);
406
+ if (text) lastUser = text;
407
+ }
408
+ if (lastUser && lastAssistant) break;
409
+ }
410
+
411
+ if (!lastUser || !lastAssistant) return undefined;
412
+ return { user: lastUser, assistant: lastAssistant };
413
+ }
414
+
415
+ function clip(text: string): string {
416
+ return text.length > EXCHANGE_CLIP_CHARS ? `${text.slice(0, EXCHANGE_CLIP_CHARS)}…` : text;
417
+ }
418
+
419
+ /**
420
+ * 提示词。带上上一条摘要做上下文延续 —— 摘要于是是"渐进式"的(新回合的信息叠加到旧摘要上),
421
+ * 而不是只看最后一轮。这不需要任何存储:`currentRecap` 就在内存里。
422
+ * 语言写死中文,直接写在规则里(不做成可配项,所以没有 LANGUAGE 常量)。
423
+ */
424
+ function buildPrompt(exchange: { user: string; assistant: string }, previousRecap: string | undefined): string {
425
+ return [
426
+ "You are an informative session recap generator.",
427
+ `Summarise the session progress in ONE single line of at most ${MAX_RECAP_CHARS} characters.`,
428
+ "Include only the action, the target, and the result or current progress.",
429
+ "No greetings, no explanations, no markdown, no surrounding quotes, no leading label like 'Recap:'.",
430
+ "- Always output in Chinese (中文).",
431
+ previousRecap ? `Previous recap (keep continuity and update it): ${previousRecap}` : "",
432
+ `Latest user request: ${clip(exchange.user)}`,
433
+ `Latest assistant response: ${clip(exchange.assistant)}`,
434
+ ]
435
+ .filter(Boolean)
436
+ .join("\n");
437
+ }
438
+
439
+ /** 清洗模型输出:只留第一行,去掉引号 / markdown 包裹 / 冗余前缀,超长截断。 */
440
+ function cleanRecapText(raw: string): string {
441
+ let text = (raw ?? "").trim();
442
+ if (!text) return "";
443
+
444
+ // 只取第一行(提示词要求单行,但模型偶尔会多给几行)。
445
+ text = text.split("\n")[0].trim();
446
+
447
+ // 去掉整体包裹的引号。
448
+ if (text.length >= 2 && ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'")))) {
449
+ const inner = text.slice(1, -1).trim();
450
+ if (inner) text = inner;
451
+ }
452
+
453
+ // 去掉 **bold** / __bold__ 包裹。
454
+ text = text.replace(/^(\*\*|__)(.+)\1$/, "$2").trim();
455
+
456
+ // 去掉 "Recap:" / "摘要:" 之类的冗余前缀。
457
+ text = text.replace(/^(recap|summary|session recap|current recap|摘要)\s*[::]\s*/i, "").trim();
458
+
459
+ if (!text) return "";
460
+ return text.length > MAX_RECAP_CHARS ? `${text.slice(0, MAX_RECAP_CHARS - 1)}…` : text;
461
+ }
462
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Tests for subagents.ts — the "is a subagent still running?" probe.
3
+ *
4
+ * Run with: node --test clients/pi/extensions/recap/subagents.test.ts
5
+ *
6
+ * The module never imports pi (the event bus is injected as a minimal interface), so a
7
+ * fake bus that answers `subagents:rpc:v1:request` from a table is enough to cover the
8
+ * probe: the well-formed-reply path, the unknown-shape paths, and every fail-open exit
9
+ * (no responder, wrong request id, throwing emit).
10
+ */
11
+
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+
15
+ import {
16
+ hasActiveSubagentWork,
17
+ isSubagentWorkActive,
18
+ nextSubagentRequestId,
19
+ SUBAGENT_RPC_REPLY_PREFIX,
20
+ SUBAGENT_RPC_REQUEST,
21
+ SUBAGENT_RPC_VERSION,
22
+ type SubagentEventBus,
23
+ } from "./subagents.ts";
24
+
25
+ /** A fleet-status reply as pi-subagents' RPC `status` method returns it. */
26
+ function fleetReply(totalActive: number, extra: Record<string, unknown> = {}): unknown {
27
+ return {
28
+ version: 1,
29
+ requestId: "x",
30
+ method: "status",
31
+ success: true,
32
+ data: { text: "…", fleet: { version: 1, entries: [], totalActive, omitted: 0 }, ...extra },
33
+ };
34
+ }
35
+
36
+ interface FakeBus {
37
+ bus: SubagentEventBus;
38
+ requests: Array<Record<string, unknown>>;
39
+ handlerCount(channel: string): number;
40
+ }
41
+
42
+ /**
43
+ * Fake bus. The responder is called synchronously on emit of the request channel and
44
+ * its return value (if any) is delivered on `subagents:rpc:v1:reply:<requestId>`.
45
+ */
46
+ function fakeBus(responder?: (request: Record<string, unknown>) => unknown): FakeBus {
47
+ const handlers = new Map<string, Set<(data: unknown) => void>>();
48
+ const requests: Array<Record<string, unknown>> = [];
49
+ const bus: SubagentEventBus = {
50
+ on(channel, handler) {
51
+ const set = handlers.get(channel) ?? new Set<(data: unknown) => void>();
52
+ handlers.set(channel, set);
53
+ set.add(handler);
54
+ return () => {
55
+ set.delete(handler);
56
+ };
57
+ },
58
+ emit(channel, data) {
59
+ if (channel !== SUBAGENT_RPC_REQUEST) return;
60
+ const request = data as Record<string, unknown>;
61
+ requests.push(request);
62
+ const replyChannel = `${SUBAGENT_RPC_REPLY_PREFIX}${String(request.requestId)}`;
63
+ const reply = responder?.(request);
64
+ if (reply !== undefined) for (const handler of handlers.get(replyChannel) ?? []) handler(reply);
65
+ },
66
+ };
67
+ return { bus, requests, handlerCount: (channel) => handlers.get(channel)?.size ?? 0 };
68
+ }
69
+
70
+ describe("isSubagentWorkActive", () => {
71
+ it("rejects non-records and failed replies", () => {
72
+ assert.equal(isSubagentWorkActive(undefined), false);
73
+ assert.equal(isSubagentWorkActive("running"), false);
74
+ assert.equal(isSubagentWorkActive([]), false);
75
+ assert.equal(isSubagentWorkActive({ success: false, error: { code: "no_active_session" } }), false);
76
+ });
77
+
78
+ it("reads the fleet status DTO's totalActive", () => {
79
+ assert.equal(isSubagentWorkActive(fleetReply(0)), false);
80
+ assert.equal(isSubagentWorkActive(fleetReply(2)), true);
81
+ assert.equal(isSubagentWorkActive(fleetReply(1, { fleet: { version: 1, totalActive: "1" } })), false);
82
+ });
83
+
84
+ it("falls back to the async snapshot when the fleet DTO is absent", () => {
85
+ const snapshot = (state: string) => ({
86
+ success: true,
87
+ data: { asyncSnapshot: { runs: [{ id: "run-1", state }] } },
88
+ });
89
+ assert.equal(isSubagentWorkActive(snapshot("running")), true);
90
+ assert.equal(isSubagentWorkActive(snapshot("queued")), true);
91
+ assert.equal(isSubagentWorkActive(snapshot("paused")), false);
92
+ assert.equal(isSubagentWorkActive({ success: true, data: {} }), false);
93
+ assert.equal(isSubagentWorkActive({ success: true, data: { asyncSnapshot: {} } }), false);
94
+ });
95
+ });
96
+
97
+ describe("hasActiveSubagentWork", () => {
98
+ it("sends a well-formed status request and reads the reply", async () => {
99
+ const { bus, requests, handlerCount } = fakeBus(() => fleetReply(2));
100
+
101
+ assert.equal(await hasActiveSubagentWork(bus), true);
102
+ assert.equal(requests.length, 1);
103
+ const request = requests[0];
104
+ assert.equal(request.version, SUBAGENT_RPC_VERSION);
105
+ assert.equal(request.method, "status");
106
+ assert.deepEqual(request.params, {});
107
+ assert.equal(typeof request.requestId, "string");
108
+ assert.notEqual(String(request.requestId).length, 0);
109
+ // The reply subscription is unique per request and removed once the promise settles.
110
+ assert.equal(handlerCount(`${SUBAGENT_RPC_REPLY_PREFIX}${String(request.requestId)}`), 0);
111
+ });
112
+
113
+ it("is false for an idle fleet", async () => {
114
+ const { bus } = fakeBus(() => fleetReply(0));
115
+ assert.equal(await hasActiveSubagentWork(bus), false);
116
+ });
117
+
118
+ it("fails open when nothing answers (timeout)", async () => {
119
+ const { bus, handlerCount } = fakeBus();
120
+ const requestId = nextSubagentRequestId();
121
+ assert.equal(await hasActiveSubagentWork(bus, { timeoutMs: 10, requestId }), false);
122
+ assert.equal(handlerCount(`${SUBAGENT_RPC_REPLY_PREFIX}${requestId}`), 0);
123
+ });
124
+
125
+ it("ignores replies for other requests", async () => {
126
+ const { bus } = fakeBus((request) => ({ version: 1, requestId: `${String(request.requestId)}-other`, success: true, data: {} }));
127
+ assert.equal(await hasActiveSubagentWork(bus, { timeoutMs: 10 }), false);
128
+ });
129
+
130
+ it("fails open when emit throws", async () => {
131
+ const bus: SubagentEventBus = {
132
+ on: () => () => {},
133
+ emit: () => {
134
+ throw new Error("no bus");
135
+ },
136
+ };
137
+ assert.equal(await hasActiveSubagentWork(bus), false);
138
+ });
139
+
140
+ it("generates unique request ids", () => {
141
+ const ids = new Set([nextSubagentRequestId(), nextSubagentRequestId(), nextSubagentRequestId()]);
142
+ assert.equal(ids.size, 3);
143
+ });
144
+ });