pi-skill-evolve 0.1.3 → 0.1.5

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 (36) hide show
  1. package/extensions/skill-evolve/lib/aggregate.ts +153 -0
  2. package/extensions/skill-evolve/lib/command.ts +1 -1
  3. package/extensions/skill-evolve/lib/config.ts +7 -7
  4. package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
  5. package/extensions/skill-evolve/lib/dispatch.ts +32 -52
  6. package/extensions/skill-evolve/lib/evolution.ts +1 -0
  7. package/extensions/skill-evolve/lib/report.ts +4 -1
  8. package/extensions/skill-evolve/lib/runs.ts +35 -2
  9. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
  10. package/extensions/skill-evolve/lib/telemetry.ts +2 -0
  11. package/extensions/skill-evolve/lib/web/applied.ts +4 -0
  12. package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
  13. package/extensions/skill-evolve/lib/web/data.ts +13 -226
  14. package/extensions/skill-evolve/lib/web/done.ts +65 -0
  15. package/extensions/skill-evolve/lib/web/filter.ts +4 -4
  16. package/extensions/skill-evolve/lib/web/pending.ts +191 -0
  17. package/extensions/skill-evolve/lib/web/render-rows.ts +37 -6
  18. package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
  19. package/extensions/skill-evolve/lib/web/render-session.ts +67 -0
  20. package/extensions/skill-evolve/lib/web/reports.ts +11 -5
  21. package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
  22. package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
  23. package/extensions/skill-evolve/lib/web/server.ts +13 -1
  24. package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
  25. package/extensions/skill-evolve/lib/web/session-view.ts +260 -0
  26. package/extensions/skill-evolve/lib/web/style.ts +17 -0
  27. package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
  28. package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
  29. package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
  30. package/extensions/skill-evolve/test/config.test.ts +10 -10
  31. package/extensions/skill-evolve/test/dispatch-supervisor.test.ts +2 -1
  32. package/extensions/skill-evolve/test/evolution.test.ts +6 -6
  33. package/extensions/skill-evolve/test/session-page.test.ts +194 -0
  34. package/extensions/skill-evolve/test/session-view.test.ts +192 -0
  35. package/extensions/skill-evolve/test/web-session-column.test.ts +141 -0
  36. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * 只读 Web 面板的数据聚合:把 `.pi/evolution/` 的散落文件拼成三个视图要的形状。
2
+ * 只读 Web 面板的数据聚合:把 `.pi/evolution/` 的散落文件拼成五个视图要的形状。
3
3
  *
4
4
  * 三条纪律:
5
5
  * 1. **只读**:这里不写盘、不开监听、不改任何状态 —— 面板永远不能变成第二个写入方;
@@ -7,60 +7,34 @@
7
7
  * null / [],绝不因为一条烂记录让整页 500(用户从飞书点进来只想要个概览);
8
8
  * 3. **数字不重算**:summary 直接复用 collectPanelState,和 TUI 状态栏同一口径 ——
9
9
  * 两处各算一套,迟早出现「TUI 说待批 2、网页说待批 3」。
10
+ *
11
+ * 这里只留**视图形状 + 聚合入口**:四类待处理条目的判定在 ./pending.ts,已处理在 ./done.ts,
12
+ * 待审批 / 已采纳 / 报告各自一个文件 —— 合在一起必然破 300 行闸门。
10
13
  */
11
- import * as fs from "node:fs";
12
- import * as path from "node:path";
13
14
  import { collectPanelState } from "../panel.ts";
14
- import { readQueue, type QueueItem } from "../queue.ts";
15
- import { listRuns, type RunSummary } from "../runs.ts";
16
- import { CHILD_LOG } from "../spawn-plan.ts";
17
- import { classifyStale, readLogTail } from "../stale.ts";
18
- import { readRunError } from "../stale.ts";
19
- import { asNumber, asText } from "./json-read.ts";
15
+ import { readQueue } from "../queue.ts";
16
+ import { listRuns } from "../runs.ts";
20
17
  import { buildApplied, type AppliedItem } from "./applied.ts";
21
18
  import { buildApprovals, type ApprovalItem } from "./approvals.ts";
19
+ import { buildDone, type DoneItem } from "./done.ts";
20
+ import { buildPending, type PendingItem } from "./pending.ts";
22
21
  import { buildReports, type RunReport } from "./reports.ts";
23
22
 
24
23
  export { MAX_FINDING_ROWS } from "./reports.ts";
25
24
  export type { FindingRow, RunReport } from "./reports.ts";
26
25
  export type { ApprovalItem } from "./approvals.ts";
27
26
  export type { AppliedItem } from "./applied.ts";
27
+ export type { PendingItem, PendingKind } from "./pending.ts";
28
+ export type { DoneItem } from "./done.ts";
29
+ export { DONE_LIMIT } from "./done.ts";
28
30
 
29
- /** 列表页(技能管理页 `skills` 与两个详情页 `run` / `skill` 不在这个联合里,见 filter.ts 的 PANEL_VIEWS) */
31
+ /** 列表页(技能管理页 `skills` 与三个详情页 `run` / `skill` / `session` 不在这个联合里,见 filter.ts 的 PANEL_VIEWS) */
30
32
  export const WEB_VIEWS = ["pending", "approvals", "applied", "done", "reports"] as const;
31
33
  export type WebView = (typeof WEB_VIEWS)[number];
32
34
 
33
- /** 已处理列表上限:网页是「翻最近发生了什么」,不是审计日志(审计看 runs/ 目录本身) */
34
- export const DONE_LIMIT = 50;
35
35
  /** 扫描最近多少个 run:卡死 / 待入队这类判断只需要看近期,runs/ 里可能有几百个历史目录 */
36
36
  const SCAN_LIMIT = 100;
37
37
 
38
- /** 单 run 默认上限:与 DEFAULT_CONFIG.maxRuntimeMs 同值,面板只用来算「上限 N 分钟」 */
39
- const DEFAULT_MAX_RUNTIME_MS = 20 * 60_000;
40
-
41
- /** 待处理四来源:正在跑 / 已判卡死 / 队列待批 / 评审通过但还没入队 */
42
- export type PendingKind = "running" | "approval" | "stale" | "reviewed";
43
-
44
- export interface PendingItem {
45
- runId: string;
46
- kind: PendingKind;
47
- skill: string;
48
- at: number | null;
49
- /** 展开后的纯文本(渲染层负责转义) */
50
- detail: string;
51
- }
52
-
53
- export interface DoneItem {
54
- runId: string;
55
- status: string;
56
- /** queue = 采纳队列(有层级与详情);run = 只有 run.json 的终态(比如 rejected 从未入队) */
57
- source: "queue" | "run";
58
- level: string;
59
- skill: string;
60
- at: number | null;
61
- detail: string;
62
- }
63
-
64
38
  export interface WebSummary {
65
39
  reviewing: number;
66
40
  stale: number;
@@ -89,193 +63,6 @@ export interface WebStateDeps {
89
63
  maxRuntimeMs?: number;
90
64
  }
91
65
 
92
- function defaultAlive(pid: number): boolean {
93
- try {
94
- process.kill(pid, 0); // 信号 0 只探测存在性,不真发信号
95
- return true;
96
- } catch {
97
- return false;
98
- }
99
- }
100
-
101
- /** 把毫秒压成「Xm Ys」:面板上人只关心量级,不关心毫秒 */
102
- function duration(ms: number): string {
103
- const total = Math.max(0, Math.floor(ms / 1000));
104
- return `${Math.floor(total / 60)}m ${total % 60}s`;
105
- }
106
-
107
- /**
108
- * 「正在跑」那一条:光看 `复盘 1` 这个数字不知道是谁、跑到哪、要不要杀,
109
- * 所以把阶段 / 进程 / 最近输出 / 看日志的命令四行摊开(用户 2026-11-08:「复盘中我也想看到状态呢」)。
110
- */
111
- function runningItem(
112
- run: RunSummary,
113
- evolutionDir: string,
114
- now: number,
115
- alive: (pid: number) => boolean,
116
- maxRuntimeMs: number,
117
- ): PendingItem | null {
118
- if (run.status !== "dispatched" || run.pid === null || !alive(run.pid)) return null;
119
- const reviewing = run.supervisorPid !== null && alive(run.supervisorPid);
120
- const logPath = path.join(evolutionDir, "runs", run.runId, CHILD_LOG);
121
- let mtime: number | null = null;
122
- try {
123
- mtime = fs.statSync(logPath).mtimeMs; // 拿不到就是还没写过日志
124
- } catch {
125
- mtime = null;
126
- }
127
- const tail = readLogTail(logPath, 1);
128
- const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
129
- const progressLine =
130
- mtime === null
131
- ? "最近输出:(还没有输出)"
132
- : `最近输出:${Math.max(0, Math.round((now - mtime) / 1000))}s 前 · 末行:${lastLine.length > 0 ? lastLine : "(还没有输出)"}`;
133
- return {
134
- runId: run.runId,
135
- kind: "running",
136
- skill: run.proposal?.skill ?? "",
137
- at: run.dispatchedAt,
138
- detail: [
139
- `阶段:${reviewing ? "评审中" : "复盘产出中"}`,
140
- `进程:pid ${run.pid} · 已跑 ${duration(now - (run.dispatchedAt ?? now))}(上限 ${Math.round(maxRuntimeMs / 60_000)} 分钟)`,
141
- progressLine,
142
- `日志:tail -f .pi/evolution/runs/${run.runId}/${CHILD_LOG}`,
143
- ].join("\n"),
144
- };
145
- }
146
-
147
- /** 四类条目的展示优先级:正在跑最上头,然后卡死、待批、待入队 */
148
- const PENDING_RANK: Record<PendingKind, number> = { running: 0, stale: 1, approval: 2, reviewed: 3 };
149
-
150
- function buildPending(
151
- items: readonly QueueItem[],
152
- runs: readonly RunSummary[],
153
- evolutionDir: string,
154
- deps: WebStateDeps = {},
155
- ): PendingItem[] {
156
- const now = deps.now ?? Date.now();
157
- const alive = deps.alive ?? defaultAlive;
158
- const maxRuntimeMs = deps.maxRuntimeMs ?? DEFAULT_MAX_RUNTIME_MS;
159
- const pending: PendingItem[] = [];
160
- // ⓪ 正在跑的:有活 pid 就是「进行中」,先列出来(它不是等批也不是失败)
161
- const running = new Set<string>();
162
- for (const run of runs) {
163
- const item = runningItem(run, evolutionDir, now, alive, maxRuntimeMs);
164
- if (!item) continue;
165
- running.add(run.runId);
166
- pending.push(item);
167
- }
168
- // ① 队列里待人批的(approval 级;auto 级不需要人,出现 pending 只是还没轮到执行)
169
- for (const item of items) {
170
- if (item.status !== "pending" || item.level !== "approval") continue;
171
- pending.push({
172
- runId: item.runId,
173
- kind: "approval",
174
- skill: item.skill,
175
- at: asNumber(item.createdAt),
176
- detail: [
177
- "层级:approval(必须人批,auto 不会替你执行)",
178
- `目标:${item.targets.join("、") || "(未写 targets)"}`,
179
- item.detail ? `备注:${item.detail}` : "",
180
- `操作:去「待审批」页(?view=approvals)直接点批准 / 拒绝,也可以在这里回 TUI 敲 /skill-evolve approve ${item.runId}`,
181
- ]
182
- .filter((line) => line.length > 0)
183
- .join("\n"),
184
- });
185
- }
186
- // ② 已判定卡死的 run:它是「失败」而不是「等批」,单独一类,别混进待批数字里。
187
- // 这里必须包含「判据命中、run.json 还没落盘」的那一批:顶部 summary.stale 用的是
188
- // collectPanelState 的同一口径,少一类就会出现「头顶说卡死 5、列表只有 3 条」。
189
- for (const run of runs) {
190
- if (running.has(run.runId)) continue; // 同一个 run 不能既「进行中」又「卡死」
191
- const persisted = run.status === "stale";
192
- if (!persisted && !classifyStale(run, now).stale) continue;
193
- const error = readRunError(path.join(evolutionDir, "runs", run.runId));
194
- const tail = error?.childLogTail ?? [];
195
- const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
196
- pending.push({
197
- runId: run.runId,
198
- kind: "stale",
199
- skill: run.proposal?.skill ?? "",
200
- at: error?.at ?? run.dispatchedAt,
201
- detail: [
202
- `原因:${error?.reason === "timeout" ? "超时(子进程还活着但超过单 run 上限)" : "子进程已退出"}`,
203
- lastLine.length > 0 ? `日志末行:${lastLine}` : "日志末行:(子进程没留下任何输出)",
204
- `排查:cat ${path.join(evolutionDir, "runs", run.runId, "run-error.json")}`,
205
- "不会自动重跑:需要重新复盘就 /skill-evolve run",
206
- ].join("\n"),
207
- });
208
- }
209
- // ④ 子进程已产出结论、评审还没跑的:不是失败(子进程本来就该退出),属于「等下一轮接上」
210
- for (const run of runs) {
211
- if (run.status !== "analyzed" && run.status !== "proposed") continue;
212
- pending.push({
213
- runId: run.runId,
214
- kind: "reviewed",
215
- skill: run.proposal?.skill ?? "",
216
- at: run.dispatchedAt,
217
- detail: [
218
- `状态:已出结论(${run.status}),评审还没跑`,
219
- "会自动接上:下次 settle 或 session_start 继续;想立即接上就 /skill-evolve run",
220
- `证据:cat ${path.join(evolutionDir, "runs", run.runId, "01-findings.json")}`,
221
- ].join("\n"),
222
- });
223
- }
224
- // ③ 评审通过但还没进队列(入队发生在 settle / session_start,中间可能还没轮到)
225
- const queued = new Set(items.map((item) => item.runId));
226
- for (const run of runs) {
227
- if (run.status !== "reviewed" || queued.has(run.runId)) continue;
228
- pending.push({
229
- runId: run.runId,
230
- kind: "reviewed",
231
- skill: run.proposal?.skill ?? "",
232
- at: run.dispatchedAt,
233
- detail: [
234
- `评审:${asText(run.review?.verdict, "(未写 verdict)")} · 建议动作 ${asText(run.review?.action, "无")}`,
235
- "状态:评审已通过,尚未入队(settle / session_start 会自动补)",
236
- `兜底:/skill-evolve approve ${run.runId}`,
237
- ].join("\n"),
238
- });
239
- }
240
- return pending.sort((a, b) => PENDING_RANK[a.kind] - PENDING_RANK[b.kind] || (b.at ?? 0) - (a.at ?? 0));
241
- }
242
-
243
- /**
244
- * 「已处理」收的终态:回滚 / 失败 / 否决 / 被取代。
245
- * **不含 applied** —— 采纳有自己的页(见 ./applied.ts),两边都收就等于两个 tab 都算一遍。
246
- */
247
- const DONE_STATUSES = new Set(["rolled-back", "failed", "rejected", "superseded"]);
248
-
249
- function buildDone(items: readonly QueueItem[], runs: readonly RunSummary[]): DoneItem[] {
250
- const byRun = new Map<string, DoneItem>();
251
- for (const run of runs) {
252
- if (!DONE_STATUSES.has(run.status)) continue;
253
- byRun.set(run.runId, {
254
- runId: run.runId,
255
- status: run.status,
256
- source: "run",
257
- level: asText(run.review?.action, "-"),
258
- skill: run.proposal?.skill ?? "",
259
- at: run.dispatchedAt,
260
- detail: run.review?.verdict ? `评审:${run.review.verdict}` : "",
261
- });
262
- }
263
- // 队列记录覆盖同 runId:它有层级(auto/approval)与失败详情,比 run.json 只有状态有用
264
- for (const item of items) {
265
- if (!DONE_STATUSES.has(item.status)) continue;
266
- byRun.set(item.runId, {
267
- runId: item.runId,
268
- status: item.status,
269
- source: "queue",
270
- level: item.level,
271
- skill: item.skill,
272
- at: asNumber(item.createdAt),
273
- detail: item.detail ?? "",
274
- });
275
- }
276
- return [...byRun.values()].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, DONE_LIMIT);
277
- }
278
-
279
66
  export function collectWebState(input: { evolutionDir: string } & WebStateDeps): WebState {
280
67
  const { evolutionDir, ...deps } = input;
281
68
  const items = readQueue(evolutionDir);
@@ -285,7 +72,7 @@ export function collectWebState(input: { evolutionDir: string } & WebStateDeps):
285
72
  pending: buildPending(items, runs, evolutionDir, deps),
286
73
  approvals: buildApprovals(evolutionDir, items),
287
74
  applied: buildApplied(items, evolutionDir),
288
- done: buildDone(items, runs),
75
+ done: buildDone(items, runs, evolutionDir),
289
76
  reports: buildReports(evolutionDir, runs),
290
77
  summary: {
291
78
  reviewing: panel.reviewing,
@@ -0,0 +1,65 @@
1
+ /**
2
+ * 「已处理」视图的数据:回滚 / 失败 / 否决 / 被取代这四种终态。
3
+ *
4
+ * 为什么不和「已采纳」混:采纳有自己的页(见 ./applied.ts),两边都收就等于两个 tab 各算一遍。
5
+ * 队列记录会覆盖同一个 runId 的 run.json 版本:它有层级(auto/approval)与失败详情,比只有状态有用。
6
+ */
7
+ import type { QueueItem } from "../queue.ts";
8
+ import type { RunSummary } from "../runs.ts";
9
+ import { asNumber, asText } from "./json-read.ts";
10
+ import { sessionForRun, sessionIndex, sessionOfRun, type SessionRef } from "./run-session.ts";
11
+
12
+ /** 已处理列表上限:网页是「翻最近发生了什么」,不是审计日志(审计看 runs/ 目录本身) */
13
+ export const DONE_LIMIT = 50;
14
+
15
+ export interface DoneItem {
16
+ runId: string;
17
+ status: string;
18
+ /** queue = 采纳队列(有层级与详情);run = 只有 run.json 的终态(比如 rejected 从未入队) */
19
+ source: "queue" | "run";
20
+ /** 会话信息:行上「会话」列要用(缺 = NO_SESSION) */
21
+ session: SessionRef;
22
+ level: string;
23
+ skill: string;
24
+ at: number | null;
25
+ detail: string;
26
+ }
27
+
28
+ /**
29
+ * 「已处理」收的终态:回滚 / 失败 / 否决 / 被取代。
30
+ * **不含 applied** —— 采纳有自己的页(见 ./applied.ts),两边都收就等于两个 tab 都算一遍。
31
+ */
32
+ const DONE_STATUSES = new Set(["rolled-back", "failed", "rejected", "superseded"]);
33
+
34
+ export function buildDone(items: readonly QueueItem[], runs: readonly RunSummary[], evolutionDir: string): DoneItem[] {
35
+ const byRun = new Map<string, DoneItem>();
36
+ const sessions = sessionIndex(runs);
37
+ for (const run of runs) {
38
+ if (!DONE_STATUSES.has(run.status)) continue;
39
+ byRun.set(run.runId, {
40
+ runId: run.runId,
41
+ status: run.status,
42
+ source: "run",
43
+ session: sessionOfRun(run),
44
+ level: asText(run.review?.action, "-"),
45
+ skill: run.proposal?.skill ?? "",
46
+ at: run.dispatchedAt,
47
+ detail: run.review?.verdict ? `评审:${run.review.verdict}` : "",
48
+ });
49
+ }
50
+ // 队列记录覆盖同 runId:它有层级(auto/approval)与失败详情,比 run.json 只有状态有用
51
+ for (const item of items) {
52
+ if (!DONE_STATUSES.has(item.status)) continue;
53
+ byRun.set(item.runId, {
54
+ runId: item.runId,
55
+ status: item.status,
56
+ source: "queue",
57
+ session: sessionForRun(item.runId, sessions, evolutionDir),
58
+ level: item.level,
59
+ skill: item.skill,
60
+ at: asNumber(item.createdAt),
61
+ detail: item.detail ?? "",
62
+ });
63
+ }
64
+ return [...byRun.values()].sort((a, b) => (b.at ?? 0) - (a.at ?? 0)).slice(0, DONE_LIMIT);
65
+ }
@@ -174,12 +174,12 @@ export function parseView(raw: string | null): WebView {
174
174
  }
175
175
 
176
176
  /**
177
- * 面板上的全部页面:五个列表页 + 技能管理 + 两个详情页。
177
+ * 面板上的全部页面:五个列表页 + 技能管理 + 三个详情页。
178
178
  *
179
- * 详情页(run / skill)与技能管理页**需要额外参数**(runId / name / days),所以它们不能进 `VIEW_ORDER`,
180
- * 也不能靠 `parseView` 认 —— 这是两件事,两个函数各自负责一件事。
179
+ * 详情页(run / skill / session)与技能管理页**需要额外参数**(runId / name / days / sessionId),
180
+ * 所以它们不能进 `VIEW_ORDER`, 也不能靠 `parseView` 认 —— 这是两件事,两个函数各自负责一件事。
181
181
  */
182
- export const PANEL_VIEWS = [...WEB_VIEWS, "skills", "skill", "run"] as const;
182
+ export const PANEL_VIEWS = [...WEB_VIEWS, "skills", "skill", "run", "session"] as const;
183
183
  export type PanelView = (typeof PANEL_VIEWS)[number];
184
184
 
185
185
  /** `?view=` → 全页面名;只认白名单,其余(含 null、`../`)回落待处理页 */
@@ -0,0 +1,191 @@
1
+ /**
2
+ * 「待处理」视图的数据:正在跑 / 已判卡死 / 队列待批 / 评审通过但还没入队。
3
+ *
4
+ * 为什么从 data.ts 拆出来:`data.ts` 只留视图形状与聚合入口,四类条目的判定各有各的判据
5
+ * (活 pid、classifyStale、队列层级、run 状态),合在一起必然破 300 行闸门。
6
+ * 纪律不变:只读、坏数据当空(判不了的那一条退化成不出现,绝不整页 500)。
7
+ */
8
+ import * as fs from "node:fs";
9
+ import * as path from "node:path";
10
+ import type { QueueItem } from "../queue.ts";
11
+ import type { RunSummary } from "../runs.ts";
12
+ import { CHILD_LOG } from "../spawn-plan.ts";
13
+ import { classifyStale, readLogTail, readRunError } from "../stale.ts";
14
+ import type { WebStateDeps } from "./data.ts";
15
+ import { asNumber, asText } from "./json-read.ts";
16
+ import { sessionForRun, sessionIndex, sessionOfRun, type SessionRef } from "./run-session.ts";
17
+
18
+ /** 单 run 默认上限:与 DEFAULT_CONFIG.maxRuntimeMs 同值,面板只用来算「上限 N 分钟」 */
19
+ const DEFAULT_MAX_RUNTIME_MS = 20 * 60_000;
20
+
21
+ function defaultAlive(pid: number): boolean {
22
+ try {
23
+ process.kill(pid, 0); // 信号 0 只探测存在性,不真发信号
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ /** 待处理四来源:正在跑 / 已判卡死 / 队列待批 / 评审通过但还没入队 */
31
+ export type PendingKind = "running" | "approval" | "stale" | "reviewed";
32
+
33
+ export interface PendingItem {
34
+ runId: string;
35
+ kind: PendingKind;
36
+ /** 这条 run 是哪个会话跑出来的:行上「会话」列要用(缺 = NO_SESSION) */
37
+ session: SessionRef;
38
+ skill: string;
39
+ at: number | null;
40
+ /** 展开后的纯文本(渲染层负责转义) */
41
+ detail: string;
42
+ }
43
+
44
+
45
+ /** 把毫秒压成「Xm Ys」:面板上人只关心量级,不关心毫秒 */
46
+ function duration(ms: number): string {
47
+ const total = Math.max(0, Math.floor(ms / 1000));
48
+ return `${Math.floor(total / 60)}m ${total % 60}s`;
49
+ }
50
+
51
+ /**
52
+ * 「正在跑」那一条:光看 `复盘 1` 这个数字不知道是谁、跑到哪、要不要杀,
53
+ * 所以把阶段 / 进程 / 最近输出 / 看日志的命令四行摊开(用户 2026-11-08:「复盘中我也想看到状态呢」)。
54
+ */
55
+ function runningItem(
56
+ run: RunSummary,
57
+ evolutionDir: string,
58
+ now: number,
59
+ alive: (pid: number) => boolean,
60
+ maxRuntimeMs: number,
61
+ ): PendingItem | null {
62
+ if (run.status !== "dispatched" || run.pid === null || !alive(run.pid)) return null;
63
+ const reviewing = run.supervisorPid !== null && alive(run.supervisorPid);
64
+ const logPath = path.join(evolutionDir, "runs", run.runId, CHILD_LOG);
65
+ let mtime: number | null = null;
66
+ try {
67
+ mtime = fs.statSync(logPath).mtimeMs; // 拿不到就是还没写过日志
68
+ } catch {
69
+ mtime = null;
70
+ }
71
+ const tail = readLogTail(logPath, 1);
72
+ const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
73
+ const progressLine =
74
+ mtime === null
75
+ ? "最近输出:(还没有输出)"
76
+ : `最近输出:${Math.max(0, Math.round((now - mtime) / 1000))}s 前 · 末行:${lastLine.length > 0 ? lastLine : "(还没有输出)"}`;
77
+ return {
78
+ runId: run.runId,
79
+ kind: "running",
80
+ session: sessionOfRun(run),
81
+ skill: run.proposal?.skill ?? "",
82
+ at: run.dispatchedAt,
83
+ detail: [
84
+ `阶段:${reviewing ? "评审中" : "复盘产出中"}`,
85
+ `进程:pid ${run.pid} · 已跑 ${duration(now - (run.dispatchedAt ?? now))}(上限 ${Math.round(maxRuntimeMs / 60_000)} 分钟)`,
86
+ progressLine,
87
+ `日志:tail -f .pi/evolution/runs/${run.runId}/${CHILD_LOG}`,
88
+ ].join("\n"),
89
+ };
90
+ }
91
+
92
+ /** 四类条目的展示优先级:正在跑最上头,然后卡死、待批、待入队 */
93
+ const PENDING_RANK: Record<PendingKind, number> = { running: 0, stale: 1, approval: 2, reviewed: 3 };
94
+
95
+ export function buildPending(
96
+ items: readonly QueueItem[],
97
+ runs: readonly RunSummary[],
98
+ evolutionDir: string,
99
+ deps: WebStateDeps = {},
100
+ ): PendingItem[] {
101
+ const now = deps.now ?? Date.now();
102
+ const alive = deps.alive ?? defaultAlive;
103
+ const maxRuntimeMs = deps.maxRuntimeMs ?? DEFAULT_MAX_RUNTIME_MS;
104
+ const pending: PendingItem[] = [];
105
+ const byRun = sessionIndex(runs);
106
+ // ⓪ 正在跑的:有活 pid 就是「进行中」,先列出来(它不是等批也不是失败)
107
+ const running = new Set<string>();
108
+ for (const run of runs) {
109
+ const item = runningItem(run, evolutionDir, now, alive, maxRuntimeMs);
110
+ if (!item) continue;
111
+ running.add(run.runId);
112
+ pending.push(item);
113
+ }
114
+ // ① 队列里待人批的(approval 级;auto 级不需要人,出现 pending 只是还没轮到执行)
115
+ for (const item of items) {
116
+ if (item.status !== "pending" || item.level !== "approval") continue;
117
+ pending.push({
118
+ runId: item.runId,
119
+ kind: "approval",
120
+ session: sessionForRun(item.runId, byRun, evolutionDir),
121
+ skill: item.skill,
122
+ at: asNumber(item.createdAt),
123
+ detail: [
124
+ "层级:approval(必须人批,auto 不会替你执行)",
125
+ `目标:${item.targets.join("、") || "(未写 targets)"}`,
126
+ item.detail ? `备注:${item.detail}` : "",
127
+ `操作:去「待审批」页(?view=approvals)直接点批准 / 拒绝,也可以在这里回 TUI 敲 /skill-evolve approve ${item.runId}`,
128
+ ]
129
+ .filter((line) => line.length > 0)
130
+ .join("\n"),
131
+ });
132
+ }
133
+ // ② 已判定卡死的 run:它是「失败」而不是「等批」,单独一类,别混进待批数字里。
134
+ // 这里必须包含「判据命中、run.json 还没落盘」的那一批:顶部 summary.stale 用的是
135
+ // collectPanelState 的同一口径,少一类就会出现「头顶说卡死 5、列表只有 3 条」。
136
+ for (const run of runs) {
137
+ if (running.has(run.runId)) continue; // 同一个 run 不能既「进行中」又「卡死」
138
+ const persisted = run.status === "stale";
139
+ if (!persisted && !classifyStale(run, now).stale) continue;
140
+ const error = readRunError(path.join(evolutionDir, "runs", run.runId));
141
+ const tail = error?.childLogTail ?? [];
142
+ const lastLine = tail.length > 0 ? tail[tail.length - 1]!.trim() : "";
143
+ pending.push({
144
+ runId: run.runId,
145
+ kind: "stale",
146
+ session: sessionOfRun(run),
147
+ skill: run.proposal?.skill ?? "",
148
+ at: error?.at ?? run.dispatchedAt,
149
+ detail: [
150
+ `原因:${error?.reason === "timeout" ? "超时(子进程还活着但超过单 run 上限)" : "子进程已退出"}`,
151
+ lastLine.length > 0 ? `日志末行:${lastLine}` : "日志末行:(子进程没留下任何输出)",
152
+ `排查:cat ${path.join(evolutionDir, "runs", run.runId, "run-error.json")}`,
153
+ "不会自动重跑:需要重新复盘就 /skill-evolve run",
154
+ ].join("\n"),
155
+ });
156
+ }
157
+ // ④ 子进程已产出结论、评审还没跑的:不是失败(子进程本来就该退出),属于「等下一轮接上」
158
+ for (const run of runs) {
159
+ if (run.status !== "analyzed" && run.status !== "proposed") continue;
160
+ pending.push({
161
+ runId: run.runId,
162
+ kind: "reviewed",
163
+ session: sessionOfRun(run),
164
+ skill: run.proposal?.skill ?? "",
165
+ at: run.dispatchedAt,
166
+ detail: [
167
+ `状态:已出结论(${run.status}),评审还没跑`,
168
+ "会自动接上:下次 settle 或 session_start 继续;想立即接上就 /skill-evolve run",
169
+ `证据:cat ${path.join(evolutionDir, "runs", run.runId, "01-findings.json")}`,
170
+ ].join("\n"),
171
+ });
172
+ }
173
+ // ③ 评审通过但还没进队列(入队发生在 settle / session_start,中间可能还没轮到)
174
+ const queued = new Set(items.map((item) => item.runId));
175
+ for (const run of runs) {
176
+ if (run.status !== "reviewed" || queued.has(run.runId)) continue;
177
+ pending.push({
178
+ runId: run.runId,
179
+ kind: "reviewed",
180
+ session: sessionOfRun(run),
181
+ skill: run.proposal?.skill ?? "",
182
+ at: run.dispatchedAt,
183
+ detail: [
184
+ `评审:${asText(run.review?.verdict, "(未写 verdict)")} · 建议动作 ${asText(run.review?.action, "无")}`,
185
+ "状态:评审已通过,尚未入队(settle / session_start 会自动补)",
186
+ `兜底:/skill-evolve approve ${run.runId}`,
187
+ ].join("\n"),
188
+ });
189
+ }
190
+ return pending.sort((a, b) => PENDING_RANK[a.kind] - PENDING_RANK[b.kind] || (b.at ?? 0) - (a.at ?? 0));
191
+ }
@@ -12,6 +12,8 @@
12
12
  * 「看日志」是只读 GET,只读面板也照常给(它不写任何东西)。
13
13
  */
14
14
  import type { AppliedItem, ApprovalItem, DoneItem, PendingItem, RunReport, WebView } from "./data.ts";
15
+ import type { SessionRef } from "./run-session.ts";
16
+ import { sessionHref } from "./run-session.ts";
15
17
  import { escapeHtml, formatStamp, shortRunId, withId } from "./text.ts";
16
18
 
17
19
  /** 行内操作需要的上下文:csrf 与 canWrite 决定按钮渲不渲染,view 决定动作做完回哪一页 */
@@ -36,10 +38,36 @@ function linkRunId(text: string, runId: string): string {
36
38
  return `${text.slice(0, index)}${anchor}${text.slice(index + short.length)}`;
37
39
  }
38
40
 
41
+ /**
42
+ * 「会话」列:pi sessionId 前 8 位做成链接,点进去看那段对话的原文(`?view=session`)。
43
+ *
44
+ * 为什么必须有(用户 2026-11-08:「面板展示的也应该是 pi sessionId 否则我都不知道怎么看对应会话」):
45
+ * 行上原来只有 runId(会话哈希8 + 时间戳),对着 TUI 里的 sessionId 对不上号,
46
+ * 想回去看那段对话只能自己到 `~/.pi/agent/sessions/` 里翻。
47
+ * 只显示前 8 位是为了不把一行撑开;完整 id 放进 `title`(悬停可见),链接里带完整 id。
48
+ */
49
+ function sessionCell(session: SessionRef | undefined): string {
50
+ const href = session === undefined ? null : sessionHref(session);
51
+ if (href === null || session === undefined || session.sessionId === null) {
52
+ // 老 run.json 可能没有 sessionId:写「—」而不是凭空造一个空链接(点进去只会 404)
53
+ return ' · <span class="hint">会话 —</span>';
54
+ }
55
+ return ` · <a class="sess" href="${escapeHtml(href)}" title="${escapeHtml(session.sessionId)}">会话 ${escapeHtml(
56
+ session.sessionId.slice(0, 8),
57
+ )}</a>`;
58
+ }
59
+
60
+ /** 行尾附加信息:runId 变链接、会话列(都可缺省,缺了就不渲染) */
61
+ interface RowMeta {
62
+ runId?: string;
63
+ session?: SessionRef;
64
+ }
65
+
39
66
  /** 一行;detail 为空就不给 `<details>`(空折叠框比没有更烦);给了 runId 就把抬头里的 runId 变链接 */
40
- function row(head: string, detail: string, acts = "", runId?: string): string {
67
+ function row(head: string, detail: string, acts = "", meta: RowMeta = {}): string {
41
68
  const text = escapeHtml(head);
42
- const rendered = runId === undefined ? text : linkRunId(text, runId);
69
+ const linked = meta.runId === undefined ? text : linkRunId(text, meta.runId);
70
+ const rendered = `${linked}${sessionCell(meta.session)}`;
43
71
  const inner =
44
72
  detail.length === 0
45
73
  ? `<span class="head">${rendered}</span>`
@@ -90,6 +118,7 @@ export function pendingRows(items: readonly PendingItem[], ctx: RowContext): str
90
118
  `[${badge[item.kind]}] ${shortRunId(item.runId)}${skill} · ${formatStamp(item.at)}`,
91
119
  withId(item.runId, item.detail),
92
120
  rowActs(item.runId, ctx, item.kind === "stale"),
121
+ { session: item.session },
93
122
  );
94
123
  })
95
124
  .join("\n");
@@ -104,7 +133,7 @@ export function doneRows(items: readonly DoneItem[]): string {
104
133
  `[${item.status}] ${shortRunId(item.runId)}${skill}${level} · ${formatStamp(item.at)}`,
105
134
  withId(item.runId, item.detail),
106
135
  archiveActs(item.runId),
107
- item.runId,
136
+ { runId: item.runId, session: item.session },
108
137
  );
109
138
  })
110
139
  .join("\n");
@@ -144,7 +173,7 @@ export function appliedRows(items: readonly AppliedItem[]): string {
144
173
  `[applied] ${shortRunId(item.runId)}${skill}${delta} · ${formatStamp(item.appliedAt)} · ${gateText(item)}`,
145
174
  appliedDetail(item),
146
175
  archiveActs(item.runId),
147
- item.runId,
176
+ { runId: item.runId, session: item.session },
148
177
  );
149
178
  })
150
179
  .join("\n");
@@ -186,7 +215,9 @@ function actionForm(action: "approve" | "reject", item: ApprovalItem, csrf: stri
186
215
  export function approvalRows(items: readonly ApprovalItem[], ctx: RowContext): string {
187
216
  return items
188
217
  .map((item) => {
189
- const detail = `<details><summary class="head">${escapeHtml(approvalHead(item))}</summary><pre>${escapeHtml(
218
+ const detail = `<details><summary class="head">${escapeHtml(approvalHead(item))}${sessionCell(
219
+ item.session,
220
+ )}</summary><pre>${escapeHtml(
190
221
  withId(item.runId, approvalDetail(item)),
191
222
  )}</pre></details>`;
192
223
  if (!ctx.writable) return `<li class="row">${detail}</li>`;
@@ -245,7 +276,7 @@ export function reportRows(reports: readonly RunReport[], ctx: RowContext): stri
245
276
  `[${label}] ${shortRunId(report.runId)}${findings}${proposal}${verdict} · ${formatStamp(report.startedAt)}`,
246
277
  withId(report.runId, reportDetail(report)),
247
278
  rowActs(report.runId, ctx, report.status === "stale"),
248
- report.runId,
279
+ { runId: report.runId, session: report.session },
249
280
  );
250
281
  })
251
282
  .join("\n");