@zhushanwen/pi-subagents 0.0.1

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 (37) hide show
  1. package/agents/context-builder.md +19 -0
  2. package/agents/oracle.md +19 -0
  3. package/agents/planner.md +19 -0
  4. package/agents/researcher.md +19 -0
  5. package/agents/reviewer.md +19 -0
  6. package/agents/scout.md +19 -0
  7. package/agents/worker.md +18 -0
  8. package/index.ts +1 -0
  9. package/package.json +59 -0
  10. package/src/commands/subagents.ts +78 -0
  11. package/src/core/agent-registry.ts +222 -0
  12. package/src/core/concurrency-pool.ts +78 -0
  13. package/src/core/event-bridge.ts +199 -0
  14. package/src/core/execution-record.ts +500 -0
  15. package/src/core/model-resolver.ts +206 -0
  16. package/src/core/output-collector.ts +118 -0
  17. package/src/core/path-encoding.ts +16 -0
  18. package/src/core/session-factory.ts +365 -0
  19. package/src/core/session-runner.ts +303 -0
  20. package/src/core/turn-limiter.ts +71 -0
  21. package/src/index.ts +104 -0
  22. package/src/runtime/config/config.ts +170 -0
  23. package/src/runtime/discovery-config.ts +135 -0
  24. package/src/runtime/execution/history-store.ts +196 -0
  25. package/src/runtime/execution/notifier.ts +209 -0
  26. package/src/runtime/execution/record-store.ts +280 -0
  27. package/src/runtime/model-config-service.ts +265 -0
  28. package/src/runtime/session-file-gc.ts +70 -0
  29. package/src/runtime/subagent-service.ts +549 -0
  30. package/src/tools/subagent-tool.ts +286 -0
  31. package/src/tui/bg-notify-render.ts +139 -0
  32. package/src/tui/config-wizard.ts +253 -0
  33. package/src/tui/format-helpers.ts +37 -0
  34. package/src/tui/format.ts +332 -0
  35. package/src/tui/list-view.ts +883 -0
  36. package/src/tui/tool-render.ts +467 -0
  37. package/src/types.ts +334 -0
@@ -0,0 +1,37 @@
1
+ // src/tui/format-helpers.ts
2
+ //
3
+ // 配置摘要格式化(/subagents 无参数时 notify 用)。
4
+ // 从 format.ts 拆出避免循环依赖(format.ts 不依赖 config 类型)。
5
+ //
6
+ // 返回纯字符串(不带 ANSI)——notify 文本走 Pi 消息通道,非对话流背景色 block。
7
+
8
+ import type { SessionModelState, SubagentsGlobalConfig } from "../types.ts";
9
+
10
+ /**
11
+ * 格式化配置摘要(一行通知)。
12
+ *
13
+ * "Subagents: max 4 · yolo off · coding=zhipu/glm-5.2 · research=anthropic/claude-sonnet-4.5 · fallback=zhipu/glm-5.2"
14
+ *
15
+ * 字段(`·` 分隔,spec 分隔符语义:同级并列字段):
16
+ * - max {maxConcurrent}
17
+ * - yolo {on|off}(globalConfig.yoloByDefault)
18
+ * - 各 category:{label}={model}(按 categories 声明序)
19
+ * - fallback={model}(兜底模型)
20
+ */
21
+ export function formatConfigSummary(
22
+ globalConfig: SubagentsGlobalConfig,
23
+ _sessionState: SessionModelState,
24
+ ): string {
25
+ const parts: string[] = [`max ${globalConfig.maxConcurrent}`];
26
+ parts.push(`yolo ${globalConfig.yoloByDefault ? "on" : "off"}`);
27
+
28
+ // 各 category 模型
29
+ for (const [name, def] of Object.entries(globalConfig.categories)) {
30
+ parts.push(`${name}=${def.model}`);
31
+ }
32
+
33
+ // 兜底模型
34
+ parts.push(`fallback=${globalConfig.fallback.model}`);
35
+
36
+ return `Subagents: ${parts.join(" · ")}`;
37
+ }
@@ -0,0 +1,332 @@
1
+ // src/tui/format.ts
2
+ //
3
+ // 纯格式化函数。零 Pi 依赖、零 runtime 依赖,可单测。
4
+ //
5
+ // 分隔符语义体系(tui-format.md §1,impeccable 审查裁定):
6
+ // `·` 同级并列字段/thinking 图标;`()` 元数据分组;`›` 工具;`>` 输出;`·` thinking。
7
+ // 禁用 `│` 做 stats 分隔、`├─`/`└─` 做 eventLog 前缀。
8
+ //
9
+ // 截断(tui-format.md §5):truncLine 是 ANSI 安全的——追踪 active SGR,省略号前重应用,
10
+ // 否则背景色在省略号处断裂(contentBox 的 applyBg 被 `\x1b[0m` 抹掉)。
11
+ // 移植自 pi-subagents render.ts:44-89。
12
+
13
+ import { visibleWidth } from "@earendil-works/pi-tui";
14
+
15
+ import type { AgentEventLogEntry, ExecutionStatus } from "../types.ts";
16
+
17
+ /**
18
+ * ThemeLike:TUI 语义 token 着色接口(duck-typed,兼容 Pi Theme)。
19
+ *
20
+ * 注意:Pi Theme **没有 `dim` 方法**——"dim" 是颜色 token,走 `fg("dim", text)`。
21
+ * 故本接口只声明 fg/bg/bold/underline,dim 文本一律 `fg("dim", ...)`。
22
+ */
23
+ export interface ThemeLike {
24
+ bg(color: string, text: string): string;
25
+ fg(color: string, text: string): string;
26
+ bold(text: string): string;
27
+ underline(text: string): string;
28
+ }
29
+
30
+ // ============================================================
31
+ // 模块级常量(复用,勿在热路径 new)
32
+ // ============================================================
33
+
34
+ /** spinner 帧序列(Braille),seed-frame 驱动,不用 setInterval。 */
35
+ const RUNNING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
36
+
37
+ /** grapheme 切分器(Unicode/emoji 安全)。模块级共享,勿热路径 new。 */
38
+ const segmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
39
+
40
+ /** eventLog label 的最大可见宽度(压缩区每条上限,超出截断)。 */
41
+ const EVENT_LINE_MAX_WIDTH = 50;
42
+
43
+ // formatTokens 阈值(三段式,与 demo 一致)
44
+ /** < 此值显示原值。 */
45
+ const TOKEN_PLAIN_MAX = 1000;
46
+ /** < 此值显示 "N.Nk";≥ 此值显示 "Nk"(四舍五入)。 */
47
+ const TOKEN_DECIMAL_K_MAX = 10000;
48
+
49
+ // formatElapsedSeconds 阈值
50
+ const SECS_PER_MINUTE = 60;
51
+ const SECS_PER_HOUR = 3600;
52
+
53
+ // ============================================================
54
+ // Token / 时长格式化
55
+ // ============================================================
56
+
57
+ /**
58
+ * 格式化 token 数(三段式,与 demo 一致)。
59
+ *
60
+ * < 1000 → 原值("820")
61
+ * < 10000 → "N.Nk"(8200 → "8.2k")
62
+ * ≥ 10000 → "Nk" 四舍五入(23000 → "23k")
63
+ */
64
+ export function formatTokens(n: number): string {
65
+ if (n < TOKEN_PLAIN_MAX) return String(n);
66
+ if (n < TOKEN_DECIMAL_K_MAX) return `${(n / TOKEN_PLAIN_MAX).toFixed(1)}k`;
67
+ return `${Math.round(n / TOKEN_PLAIN_MAX)}k`;
68
+ }
69
+
70
+ /**
71
+ * 格式化整数秒时长(对话流 block + list overlay 共用)。
72
+ * 数据源 details.elapsedSeconds 已是 Math.floor 过的整数秒。
73
+ *
74
+ * < 60 → "Xs"(12 → "12s")
75
+ * < 3600 → "Xm Ys"(72 → "1m12s")
76
+ * ≥ 3600 → "Xh Ym"
77
+ */
78
+ export function formatElapsedSeconds(seconds: number): string {
79
+ if (seconds < SECS_PER_MINUTE) return `${seconds}s`;
80
+ if (seconds < SECS_PER_HOUR) {
81
+ const m = Math.floor(seconds / SECS_PER_MINUTE);
82
+ const s = seconds % SECS_PER_MINUTE;
83
+ return `${m}m${s}s`;
84
+ }
85
+ const h = Math.floor(seconds / SECS_PER_HOUR);
86
+ const m = Math.floor((seconds % SECS_PER_HOUR) / SECS_PER_MINUTE);
87
+ return `${h}h${m}m`;
88
+ }
89
+
90
+ /**
91
+ * 把文本 pad 到指定**可见**宽度(grapheme/emoji/CJK 安全)。
92
+ *
93
+ * 用 visibleWidth 而非 `.length`——避免 ANSI 转义、emoji、宽字符(CJK 占 2 列)
94
+ * 把列对齐算错(dev guide §2.4 警告的坑)。
95
+ *
96
+ * - 已 ≥ width → 原样返回(调用方负责先 truncLine 截断)
97
+ * - < width → 末尾补空格到可见宽度对齐
98
+ *
99
+ * 与 truncLine 配对:左/右列对齐时先 `truncLine(s, colWidth)` 再 `padToVisible(s, colWidth)`。
100
+ */
101
+ export function padToVisible(text: string, width: number): string {
102
+ const w = visibleWidth(text);
103
+ if (w >= width) return text;
104
+ return text + " ".repeat(width - w);
105
+ }
106
+
107
+ /**
108
+ * 分段着色版 segFill:title 和 fill 都已着色(含 ANSI),拼接时各自 ANSI 延续。
109
+ *
110
+ * 解决 ANSI 嵌套失色问题:若用 `t.fg("c1", fill(title, "─", n))`,
111
+ * title 内的 `\x1b[0m` 会重置外层 c1,导致 title 之后的 `─` 失去 c1。
112
+ * 本函数改成 `title + fill.repeat(后)`,fill 整段保持着自己的 ANSI,不依赖外层包裹 → 全线着色一致。
113
+ *
114
+ * segFillColored(t.fg("accent"," Subagents "), t.fg("borderMuted","─"), 20)
115
+ * → accent(" Subagents ") + borderMuted(─×N),无嵌套
116
+ *
117
+ * 注意:fill 必须是「单字符着色」(如 `t.fg("borderMuted","─")`),visibleWidth=1。
118
+ * 调用方负责 title/fill 着色;本函数不接 theme。标题在前、填充在后。
119
+ */
120
+ export function segFillColored(titleStyled: string | undefined, fillStyled: string, width: number): string {
121
+ if (width <= 0) return "";
122
+ const fillW = visibleWidth(fillStyled);
123
+ if (!titleStyled || fillW === 0) {
124
+ // 纯填充线:fillStyled.visibleWidth 应为 1,按 width 次重复
125
+ return fillStyled.repeat(width);
126
+ }
127
+ const tw = visibleWidth(titleStyled);
128
+ if (tw >= width) return truncLine(titleStyled, width);
129
+ const fillCount = width - tw;
130
+ return titleStyled + fillStyled.repeat(fillCount);
131
+ }
132
+
133
+ // ============================================================
134
+ // 状态图标
135
+ // ============================================================
136
+
137
+ /**
138
+ * status → 图标 + 颜色 token。
139
+ *
140
+ * running → { icon: undefined, color: "accent" }
141
+ * icon 留空是因为 running 的 spinner 需 seed 驱动,
142
+ * 调用方用 detailsSeed(details) 算 seed 后调 spinnerGlyph(seed)。
143
+ * done → { "✓", "success" }
144
+ * failed → { "✗", "error" }
145
+ * cancelled → { "■", "muted" }
146
+ */
147
+ export function statusGlyph(status: ExecutionStatus): { icon: string | undefined; color: string } {
148
+ switch (status) {
149
+ case "running":
150
+ return { icon: undefined, color: "accent" };
151
+ case "done":
152
+ return { icon: "✓", color: "success" };
153
+ case "failed":
154
+ return { icon: "✗", color: "error" };
155
+ case "cancelled":
156
+ return { icon: "■", color: "muted" };
157
+ default:
158
+ // 防御:运行时 status 可能是意外值(SDK 投影异常/未来新增状态),兜底为 running 语义
159
+ return { icon: undefined, color: "accent" };
160
+ }
161
+ }
162
+
163
+ /**
164
+ * 生成 spinner 字形(seed 驱动,非定时器)。
165
+ *
166
+ * 每次 onUpdate(真实事件)→ seed 单调增长 → 换帧;
167
+ * 静默期 seed 不变 → 冻结 → 换取滚动体验(修复 viewport 锚定 bug)。
168
+ */
169
+ export function spinnerGlyph(seed: number): string {
170
+ // 防御:seed 可能是 NaN(details 字段缺失时),回退首帧
171
+ if (!Number.isFinite(seed)) return RUNNING_FRAMES[0]!;
172
+ return RUNNING_FRAMES[Math.abs(seed) % RUNNING_FRAMES.length]!;
173
+ }
174
+
175
+ // ============================================================
176
+ // eventLog 单行格式化
177
+ // ============================================================
178
+
179
+ /**
180
+ * 压平 label 到单行(防 LLM 输出的 \r\n/\t 把单行展开成多行,破坏布局)。
181
+ * 两层防御之一(另一层在 tool-render 的 buildRenderLines)。
182
+ */
183
+ export function sanitizeLabel(label: string): string {
184
+ return label.replace(/[\r\n]+/g, " ").replace(/\t/g, " ");
185
+ }
186
+
187
+ // ============================================================
188
+ // 共享文本/参数提取 helper(tool-render / list-view / bg-notify-render / subagent-tool 复用)
189
+ // ============================================================
190
+
191
+ /**
192
+ * 取文本首个非空行(多行压成首行)。
193
+ *
194
+ * 仅做"取首行"——不 sanitize。三处调用方的 sanitize 末步不同
195
+ * (tool-render 调 sanitizeLabel、bg-notify-render 压 \r\t、list-view 不处理),
196
+ * 故共享此基础函数,各自按需 wrap。
197
+ *
198
+ * firstLine("a\nb\nc") → "a"
199
+ * firstLine("\n\nb") → "b"
200
+ * firstLine("") → ""
201
+ */
202
+ export function firstLine(text?: string): string {
203
+ if (!text) return "";
204
+ return text.split("\n").find((l) => l.trim())?.trim() ?? "";
205
+ }
206
+
207
+ /**
208
+ * 从 renderCall/execute 的 unknown args 安全提取 agent 名。
209
+ * 类型守卫窄化(替代 `as { agent?: string }` 全可选断言)。
210
+ * 无 agent 字段或非空字符串时默认 "worker"。
211
+ */
212
+ export function extractAgentName(args: unknown): string {
213
+ if (typeof args === "object" && args !== null && "agent" in args) {
214
+ const v = (args as { agent: unknown }).agent;
215
+ if (typeof v === "string" && v.length > 0) return v;
216
+ }
217
+ return "worker";
218
+ }
219
+
220
+ /**
221
+ * 格式化单条 eventLog 条目(带类型图标 + 着色,不含 `⎿` 前缀——前缀由调用方加)。
222
+ *
223
+ * 标签语义(tui-conversation.md §7,比单字符图标更明确):
224
+ * tool: tool_start/tool_end(尾部追加 ✓/✗)
225
+ * text: text_output
226
+ * thinking: thinking(整行 dim,含标签)
227
+ * ── turn ── turn_end(仅 expanded)
228
+ *
229
+ * label 经 sanitizeLabel 压成单行,再 truncLine 截到 EVENT_LINE_MAX_WIDTH。
230
+ */
231
+ export function formatEventLine(entry: AgentEventLogEntry, theme: ThemeLike): string {
232
+ const label = truncLine(sanitizeLabel(entry.label), EVENT_LINE_MAX_WIDTH);
233
+
234
+ switch (entry.type) {
235
+ case "tool_start":
236
+ return `tool: ${label}`;
237
+
238
+ case "tool_end": {
239
+ const mark = entry.status === "failed"
240
+ ? ` ${theme.fg("error", "✗")}`
241
+ : ` ${theme.fg("success", "✓")}`;
242
+ return `tool: ${label}${mark}`;
243
+ }
244
+
245
+ case "text_output":
246
+ return `text: ${label}`;
247
+
248
+ case "thinking":
249
+ // 推理片段:整行 dim(含标签)
250
+ return theme.fg("dim", `thinking: ${label}`);
251
+
252
+ case "turn_end":
253
+ // turn 分隔(仅 expanded view 显示)
254
+ return theme.fg("dim", "── turn ──");
255
+
256
+ case "error":
257
+ // 错误条目:标签 + label + ✗
258
+ return `tool: ${label} ${theme.fg("error", "✗")}`;
259
+
260
+ default:
261
+ return label;
262
+ }
263
+ }
264
+
265
+ // ============================================================
266
+ // ANSI 安全截断
267
+ // ============================================================
268
+
269
+ /**
270
+ * 截断文本到 maxWidth 可见宽度(带省略号 `…`,ANSI 安全)。
271
+ *
272
+ * 问题:pi-tui 的 truncateToWidth 在省略号前插 `\x1b[0m`(全局 reset),
273
+ * 导致 contentBox 施加的背景色在省略号处断裂。
274
+ *
275
+ * 解决:遍历追踪 active SGR styles,遇 `\x1b[0m` 清空、遇其他 `\x1b[..m` push,
276
+ * 截断时 `result + activeStyles.join("") + "…"`——重应用 active 样式,背景不断裂。
277
+ * 用 Intl.Segmenter grapheme 切分,正确处理 emoji/CJK/组合字符。
278
+ *
279
+ * 移植自 pi-subagents render.ts:44-89。
280
+ */
281
+ export function truncLine(text: string, maxWidth: number): string {
282
+ if (maxWidth <= 0) return "";
283
+ if (visibleWidth(text) <= maxWidth) return text;
284
+
285
+ const targetWidth = Math.max(0, maxWidth - 1);
286
+ let result = "";
287
+ let currentWidth = 0;
288
+ let activeStyles: string[] = [];
289
+ let i = 0;
290
+
291
+ while (i < text.length) {
292
+ // 捕获 ANSI SGR 序列
293
+ const ansiMatch = text.slice(i).match(/^\x1b\[[0-9;]*m/);
294
+ if (ansiMatch) {
295
+ const code = ansiMatch[0];
296
+ result += code;
297
+
298
+ if (code === "\x1b[0m" || code === "\x1b[m") {
299
+ activeStyles = []; // reset → 清空栈
300
+ } else {
301
+ activeStyles.push(code);
302
+ }
303
+ i += code.length;
304
+ continue;
305
+ }
306
+
307
+ // 找到下一段纯文本(非 ANSI)的边界
308
+ let end = i;
309
+ while (end < text.length && !text.slice(end).match(/^\x1b\[[0-9;]*m/)) {
310
+ end++;
311
+ }
312
+
313
+ // 按 grapheme 迭代这段文本,累加到 targetWidth
314
+ const textPortion = text.slice(i, end);
315
+ for (const seg of segmenter.segment(textPortion)) {
316
+ const grapheme = seg.segment;
317
+ const graphemeWidth = visibleWidth(grapheme);
318
+
319
+ if (currentWidth + graphemeWidth > targetWidth) {
320
+ // 截断:重应用 active 样式 + 省略号
321
+ return result + activeStyles.join("") + "…";
322
+ }
323
+
324
+ result += grapheme;
325
+ currentWidth += graphemeWidth;
326
+ }
327
+ i = end;
328
+ }
329
+
330
+ // 理论上 visibleWidth 检查已提前返回,此行兜底
331
+ return result + activeStyles.join("") + "…";
332
+ }