pi-skill-evolve 0.1.4 → 0.1.6

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 (47) hide show
  1. package/extensions/skill-evolve/lib/aggregate.ts +153 -0
  2. package/extensions/skill-evolve/lib/dispatch-types.ts +65 -0
  3. package/extensions/skill-evolve/lib/dispatch.ts +32 -52
  4. package/extensions/skill-evolve/lib/evolution.ts +1 -0
  5. package/extensions/skill-evolve/lib/report.ts +4 -1
  6. package/extensions/skill-evolve/lib/runs.ts +35 -2
  7. package/extensions/skill-evolve/lib/shutdown-dispatch.ts +24 -1
  8. package/extensions/skill-evolve/lib/telemetry.ts +2 -0
  9. package/extensions/skill-evolve/lib/web/applied.ts +4 -0
  10. package/extensions/skill-evolve/lib/web/approvals.ts +4 -0
  11. package/extensions/skill-evolve/lib/web/data.ts +13 -226
  12. package/extensions/skill-evolve/lib/web/done.ts +65 -0
  13. package/extensions/skill-evolve/lib/web/filter.ts +4 -4
  14. package/extensions/skill-evolve/lib/web/list-query.ts +43 -0
  15. package/extensions/skill-evolve/lib/web/markdown-lite.ts +95 -0
  16. package/extensions/skill-evolve/lib/web/pending.ts +191 -0
  17. package/extensions/skill-evolve/lib/web/render-controls.ts +58 -32
  18. package/extensions/skill-evolve/lib/web/render-rows.ts +178 -205
  19. package/extensions/skill-evolve/lib/web/render-run.ts +14 -0
  20. package/extensions/skill-evolve/lib/web/render-session.ts +138 -0
  21. package/extensions/skill-evolve/lib/web/render-skills.ts +0 -1
  22. package/extensions/skill-evolve/lib/web/render-table.ts +126 -0
  23. package/extensions/skill-evolve/lib/web/render.ts +116 -54
  24. package/extensions/skill-evolve/lib/web/reports.ts +11 -5
  25. package/extensions/skill-evolve/lib/web/run-detail.ts +6 -0
  26. package/extensions/skill-evolve/lib/web/run-session.ts +62 -0
  27. package/extensions/skill-evolve/lib/web/server.ts +21 -1
  28. package/extensions/skill-evolve/lib/web/session-file.ts +136 -0
  29. package/extensions/skill-evolve/lib/web/session-render.ts +189 -0
  30. package/extensions/skill-evolve/lib/web/session-view.ts +276 -0
  31. package/extensions/skill-evolve/lib/web/shell.ts +42 -11
  32. package/extensions/skill-evolve/lib/web/style.ts +171 -89
  33. package/extensions/skill-evolve/lib/web/text.ts +57 -0
  34. package/extensions/skill-evolve/test/aggregate-dispatch.test.ts +165 -0
  35. package/extensions/skill-evolve/test/aggregate-harness.ts +114 -0
  36. package/extensions/skill-evolve/test/aggregate.test.ts +80 -0
  37. package/extensions/skill-evolve/test/session-nav.test.ts +162 -0
  38. package/extensions/skill-evolve/test/session-page.test.ts +200 -0
  39. package/extensions/skill-evolve/test/session-render.test.ts +203 -0
  40. package/extensions/skill-evolve/test/session-view.test.ts +180 -0
  41. package/extensions/skill-evolve/test/web-applied.test.ts +30 -22
  42. package/extensions/skill-evolve/test/web-redesign.test.ts +264 -0
  43. package/extensions/skill-evolve/test/web-render.test.ts +57 -41
  44. package/extensions/skill-evolve/test/web-server.test.ts +4 -1
  45. package/extensions/skill-evolve/test/web-session-column.test.ts +139 -0
  46. package/extensions/skill-evolve/test/web-shell.test.ts +7 -5
  47. package/package.json +1 -1
@@ -0,0 +1,126 @@
1
+ /**
2
+ * 列表的表格与分组:五列(状态 / 时间 / 技能 / 提案 / 操作)+ 同会话多条 run 的 `<details>` 分组。
3
+ *
4
+ * 为什么单独一个文件:行数据怎么取(render-rows.ts)与表怎么画是两件事,
5
+ * 表这部分自己就有三块(单行、分组、组标题摘要),挤在映射文件里会破 300 行。
6
+ * 这里只认 `ListRow`,不认识任何具体视图的数据结构。
7
+ */
8
+ import { sessionHref, type SessionRef } from "./run-session.ts";
9
+ import type { ListExtras } from "./list-query.ts";
10
+ import { escapeHtml, formatClock, formatIso } from "./text.ts";
11
+ import type { ListRow } from "./render-rows.ts";
12
+
13
+ /** 表格骨架:五列固定,列宽由 CSS 管;操作列右对齐,扫行时视线不用来回跳 */
14
+ const TABLE_HEAD = `<thead><tr><th class="status">状态</th><th class="time">时间</th><th class="skill">技能</th><th class="delta">提案</th><th class="ops">操作</th></tr></thead>`;
15
+
16
+ /** 会话小字:单条会话的 run 不进组,所以会话只在这种行的状态格里出现一次 */
17
+ function sessionLine(session: SessionRef): string {
18
+ // 老 run.json 可能没有 sessionId:写「会话 —」而不是凭空造一个空链接(点进去只会 404)
19
+ if (session.sessionId === null) return `<span class="sk-sess">会话 —</span>`;
20
+ const href = sessionHref(session);
21
+ const text = escapeHtml(`会话 ${session.sessionId.slice(0, 8)}`);
22
+ const link = href === null ? text : `<a class="sess" href="${escapeHtml(href)}" title="${escapeHtml(session.sessionId)}">${text}</a>`;
23
+ return `<span class="sk-sess">${link}</span>`;
24
+ }
25
+
26
+ function rowHtml(row: ListRow, standalone: boolean, now: number): string {
27
+ const time = row.at === null ? "—" : formatClock(row.at, now);
28
+ const skill = row.skill.length === 0 ? `<span class="off">—</span>` : `<code>${escapeHtml(row.skill)}</code>`;
29
+ const delta =
30
+ row.delta === null ? `<span class="off">—</span>` : `+${row.delta.added} / -${row.delta.removed}`;
31
+ const at = row.at === null ? "" : ` title="${escapeHtml(formatIso(row.at))}"`;
32
+ const badge = `<span class="badge ${row.badge.tone}" title="${escapeHtml(row.badge.title)}">${escapeHtml(row.badge.text)}</span>`;
33
+ return `<tr>
34
+ <td class="status">${badge}${standalone ? sessionLine(row.session) : ""}</td>
35
+ <td class="time"${at}>${time}</td>
36
+ <td class="skill">${skill}</td>
37
+ <td class="delta">${delta}</td>
38
+ <td class="ops">${row.ops}</td>
39
+ </tr>`;
40
+ }
41
+
42
+ /** 一张表:表头固定五列,列宽与省略号由 CSS 管 */
43
+ function table(rows: readonly ListRow[], now: number, standalone: boolean): string {
44
+ return `<table class="rows">
45
+ ${TABLE_HEAD}
46
+ <tbody>
47
+ ${rows.map((row) => rowHtml(row, standalone, now)).join("\n")}
48
+ </tbody>
49
+ </table>`;
50
+ }
51
+
52
+ /** 一个渲染块:连续的单条 run 合成一张表;同一会话的多条 run 各成一组 */
53
+ export type RowBlock =
54
+ | { kind: "rows"; rows: ListRow[] }
55
+ | { kind: "group"; session: SessionRef; rows: ListRow[] };
56
+
57
+ /**
58
+ * 按会话分组:**只有同一 sessionId 出现两次以上才进组**。
59
+ * 单条会话的 run 包进 `<details>` 只会多一次点击(组里就一行),所以直接平铺。
60
+ * 组出现在它第一条成员的位置,成员内部按时间倒序。
61
+ */
62
+ export function groupRows(rows: readonly ListRow[]): RowBlock[] {
63
+ const counts = new Map<string, number>();
64
+ for (const row of rows) {
65
+ const id = row.session.sessionId;
66
+ if (id !== null) counts.set(id, (counts.get(id) ?? 0) + 1);
67
+ }
68
+ const emitted = new Set<string>();
69
+ const blocks: RowBlock[] = [];
70
+ for (const row of rows) {
71
+ const id = row.session.sessionId;
72
+ if (id !== null && (counts.get(id) ?? 0) > 1) {
73
+ if (emitted.has(id)) continue;
74
+ emitted.add(id);
75
+ const members = rows
76
+ .filter((item) => item.session.sessionId === id)
77
+ .sort((a, b) => (b.at ?? 0) - (a.at ?? 0));
78
+ blocks.push({ kind: "group", session: row.session, rows: members });
79
+ continue;
80
+ }
81
+ const last = blocks.at(-1);
82
+ if (last !== undefined && last.kind === "rows") last.rows.push(row);
83
+ else blocks.push({ kind: "rows", rows: [row] });
84
+ }
85
+ return blocks;
86
+ }
87
+
88
+ /**
89
+ * 组标题一行:`会话 01a1b90f · 9 条 · 最近 19:35 · 1 条需要处理`。
90
+ * 「最近」用组内最新那条的时间;「需要处理」为 0 时写「无待办」—— 组默认展开,标题就是这一组的摘要。
91
+ */
92
+ function groupSummary(block: { session: SessionRef; rows: ListRow[] }, now: number): string {
93
+ const latest = block.rows.reduce<number | null>((acc, row) => {
94
+ if (row.at === null) return acc;
95
+ return acc === null ? row.at : Math.max(acc, row.at);
96
+ }, null);
97
+ const parts = [`${block.rows.length} 条`];
98
+ if (latest !== null) parts.push(`最近 ${formatClock(latest, now)}`);
99
+ const todo = block.rows.filter((row) => row.needsAction).length;
100
+ parts.push(todo > 0 ? `${todo} 条需要处理` : "无待办");
101
+ const id = block.session.sessionId ?? "—";
102
+ const href = sessionHref(block.session);
103
+ const label =
104
+ href === null
105
+ ? escapeHtml(`会话 ${id.slice(0, 8)}`)
106
+ : `<a class="sess" href="${escapeHtml(href)}" title="${escapeHtml(id)}">${escapeHtml(`会话 ${id.slice(0, 8)}`)}</a>`;
107
+ return `${label} · ${parts.join(" · ")}`;
108
+ }
109
+
110
+ /**
111
+ * 列表正文:块与块之间留空,组默认展开(`open`),由 URL 里的 `open=none` 决定要不要收起来。
112
+ * 收起状态只能靠 URL 记住 —— 面板零 JS,`<details>` 自己的状态刷新就没了。
113
+ */
114
+ export function renderRows(rows: readonly ListRow[], extras: ListExtras, now: number): string {
115
+ const blocks = groupRows(rows);
116
+ const open = extras.open === "none" ? "" : " open";
117
+ return blocks
118
+ .map((block) => {
119
+ if (block.kind === "rows") return table(block.rows, now, true);
120
+ return `<details class="grp"${open}>
121
+ <summary>${groupSummary(block, now)}</summary>
122
+ ${table(block.rows, now, false)}
123
+ </details>`;
124
+ })
125
+ .join("\n");
126
+ }
@@ -1,31 +1,42 @@
1
1
  /**
2
2
  * Web 面板的服务端渲染:纯字符串拼 HTML,不引模板库、不发任何外部请求、不依赖 JS。
3
3
  *
4
- * 这一版只负责**列表页**:控制条 → 横幅 → 导航 → 筛选 → 列表 → 分页 → 最近操作 → 页脚。
4
+ * 这一版只负责**列表页**:粘性头部(摘要 + tabs + 控制条)→ 横幅 → 筛选 → 表格 → 分页 → 最近操作 → 页脚。
5
5
  * 页面骨架(`<head>` / 状态栏 / 六个 tab / 页脚)在 ./shell.ts;
6
- * 列表行在 ./render-rows.ts,控制条/筛选/分页/最近操作在 ./render-controls.ts,
7
- * 技能管理页在 ./render-skills.ts,run 详情页在 ./render-run.ts ——
8
- * 拆开是因为合在一起必然破 300 行,而拆开之后每段都能单独看。
6
+ * 列表行与分组在 ./render-rows.ts,控制条/筛选/分页/刷新提示/最近操作在 ./render-controls.ts,
7
+ * 技能管理页在 ./render-skills.ts,run 详情页在 ./render-run.ts —— 拆开是因为合在一起必然破 300 行。
9
8
  *
10
9
  * 渲染层不读盘:`controls`(开关与最后活动)与 `audit`(最近操作)都由服务端读好传进来。
11
10
  * 拿不到开关状态时**不渲染控制条**,而不是猜一个「已开」——顶部写错状态比不写更糟。
12
11
  *
13
- * 展开交互交给 `<details>`:零 JS,刷新即最新,坏处只是不能记住展开状态 —— 面板不需要。
12
+ * 刷新策略(用户 2026-11-08 报「待处理一直帮我收起,点开过一会又被收起」):
13
+ * 默认**不**自动刷新,只给「更新于 …· 刷新」;`&live=1` 才是用户显式要的 5 秒重载,
14
+ * 且那时页头会写清「展开态会重置」。整页重载必然丢 `<details>` 展开态,这代价只该由要求它的人承担。
14
15
  */
15
16
  import type { AuditEntry } from "./audit-log.ts";
16
17
  import type { WebControls } from "./activity.ts";
17
18
  import type { WebState, WebView } from "./data.ts";
19
+ import { NO_FILTER, buildViewPage, parseView, type ListFilter, type ViewPage } from "./filter.ts";
20
+ import { DEFAULT_EXTRAS, listHref, type ListExtras } from "./list-query.ts";
18
21
  import {
19
- NO_FILTER,
20
- buildViewPage,
21
- isDefaultFilter,
22
- pageHref,
23
- parseView,
24
- type ListFilter,
25
- type ViewPage,
26
- } from "./filter.ts";
27
- import { appliedRows, approvalRows, doneRows, pendingRows, reportRows, type RowContext } from "./render-rows.ts";
28
- import { renderAudit, renderControlBar, renderFilterForm, renderPagination } from "./render-controls.ts";
22
+ appliedRows,
23
+ approvalRows,
24
+ doneRows,
25
+ pendingRows,
26
+ reportRows,
27
+ type ListRow,
28
+ type RowContext,
29
+ } from "./render-rows.ts";
30
+ import { groupRows, renderRows } from "./render-table.ts";
31
+ import {
32
+ activityText,
33
+ renderAudit,
34
+ renderControlBar,
35
+ renderFilterForm,
36
+ renderLiveNote,
37
+ renderPagination,
38
+ renderUpdatedAt,
39
+ } from "./render-controls.ts";
29
40
  import { VIEW_LABEL, shellPage } from "./shell.ts";
30
41
  import { escapeHtml } from "./text.ts";
31
42
 
@@ -33,13 +44,31 @@ import { escapeHtml } from "./text.ts";
33
44
  export { escapeHtml, formatStamp, shortRunId } from "./text.ts";
34
45
  export { parseView } from "./filter.ts";
35
46
 
36
- /** 每个列表页的空态文案:写清「为什么空」与「空在这里意味着什么」,不是一句「暂无数据」 */
37
- export const EMPTY: Record<WebView, string> = {
38
- pending: "暂无待处理:所有复盘都已完成,也没有待人批的改动。",
39
- approvals: "没有待批:auto 级不需要人批,approval 级的会出现在这里。",
40
- applied: "还没有已采纳的提案:采纳过的会连 diff 一起留在这里。",
41
- done: "暂无已处理记录:还没有回滚、失败或被否决的提案。",
42
- reports: "暂无复盘报告:还没有 run 写过 01-findings.json。",
47
+ /**
48
+ * 每个列表页的空态:一句话说「为什么空」+ 一句下一步。
49
+ * 只写「暂无数据」等于把「接下来该敲什么」留给人自己猜,而这里每个空态都对应一个具体动作。
50
+ */
51
+ export const EMPTY: Record<WebView, { text: string; next: string }> = {
52
+ pending: {
53
+ text: "暂无待处理:所有复盘都已完成,也没有待人批的改动。",
54
+ next: "下一批提案来自真实会话:确认 <code>/skill-evolve telemetry on</code> 与 <code>/skill-evolve dispatch on</code>。",
55
+ },
56
+ approvals: {
57
+ text: "没有待批:auto 级不需要人批,approval 级的会出现在这里。",
58
+ next: "想让提案先来这儿:<code>/skill-evolve level approval</code>。",
59
+ },
60
+ applied: {
61
+ text: "还没有已采纳的提案:采纳过的会连 diff 一起留在这里。",
62
+ next: "先 <code>/skill-evolve dispatch on</code>,让真实会话自动出提案。",
63
+ },
64
+ done: {
65
+ text: "暂无已处理记录:还没有回滚、失败或被否决的提案。",
66
+ next: "只有走完终态的 run 才进这里;在跑的 run 看「待处理」。",
67
+ },
68
+ reports: {
69
+ text: "暂无复盘报告:还没有 run 写过 01-findings.json。",
70
+ next: "先 <code>/skill-evolve run</code> 手动跑一次复盘。",
71
+ },
43
72
  };
44
73
 
45
74
  /** 页脚按视图分:只有待审批页能点,其它页要把「去哪批」写清楚 */
@@ -51,31 +80,58 @@ function footer(view: WebView, canWrite: boolean): string {
51
80
  }
52
81
  return `点「批准」= 在 TUI 敲 <code>/skill-evolve approve &lt;runId&gt;</code>,走同一套五道闸;「拒绝」只改队列状态,不动任何技能文件。${api}`;
53
82
  }
54
- return `只读面板:只绑 127.0.0.1、无鉴权、不能在这里点批准 ——
55
- 采纳请回 TUI 跑 <code>/skill-evolve approve &lt;runId&gt;</code>,或去<a href="/?view=approvals">待审批页</a>直接批。${api}`;
83
+ return `本机面板 · 只绑 127.0.0.1 · 无鉴权 · 机器可读副本见 <a href="/api/state">/api/state</a>。`;
84
+ }
85
+
86
+ /** 五个视图各自的「条目 → 行」映射;字段差异都在各自函数里消化,渲染层只认 ListRow */
87
+ function rowsOf(page: ViewPage, ctx: RowContext): ListRow[] {
88
+ switch (page.view) {
89
+ case "pending":
90
+ return pendingRows(page.rows, ctx);
91
+ case "approvals":
92
+ return approvalRows(page.rows, ctx);
93
+ case "applied":
94
+ return appliedRows(page.rows);
95
+ case "done":
96
+ return doneRows(page.rows);
97
+ default:
98
+ return reportRows(page.rows, ctx);
99
+ }
56
100
  }
57
101
 
58
102
  /**
59
- * 列表区:筛选表单 + 列表 + 分页。待审批页不给筛选也不分页 ——
103
+ * 分组开关:只在真的出现了组(同会话 ≥2 条)时给链接 —— 没有组的页面给「收起分组」等于给一个点了没反应的开关。
104
+ * 两个显式链接(`open=all` / `open=none`)优先于默认值,靠 URL 记住,不用 JS。
105
+ */
106
+ function groupToggle(view: WebView, filter: ListFilter, extras: ListExtras, hasGroup: boolean): string {
107
+ if (!hasGroup) return "";
108
+ const collapsed = extras.open === "none";
109
+ const target = { ...extras, open: collapsed ? ("all" as const) : ("none" as const) };
110
+ const text = collapsed ? "展开全部分组" : "收起全部分组";
111
+ return `<p class="hint"><a href="${escapeHtml(listHref(view, filter, 1, target))}">${text}</a></p>\n`;
112
+ }
113
+
114
+ /**
115
+ * 列表区:筛选表单 + 表格(或空态)+ 分页。待审批页不给筛选也不分页 ——
60
116
  * 它必须在一屏里看全(每个都等着人点),筛掉一部分只会制造「怎么还有没批的」。
61
117
  */
62
- function listSection(view: WebView, page: ViewPage, filter: ListFilter, ctx: RowContext): string {
118
+ function listSection(
119
+ view: WebView,
120
+ page: ViewPage,
121
+ filter: ListFilter,
122
+ extras: ListExtras,
123
+ ctx: RowContext,
124
+ ): string {
125
+ const rows = rowsOf(page, ctx);
63
126
  const body =
64
- page.view === "pending"
65
- ? pendingRows(page.rows, ctx)
66
- : page.view === "approvals"
67
- ? approvalRows(page.rows, ctx)
68
- : page.view === "applied"
69
- ? appliedRows(page.rows)
70
- : page.view === "done"
71
- ? doneRows(page.rows)
72
- : reportRows(page.rows, ctx);
73
- const list = body.length === 0 ? `<p class="empty">${EMPTY[view]}</p>` : `<ul>\n${body}\n</ul>`;
74
- if (view === "approvals") return `<main>\n${list}\n</main>`;
127
+ rows.length === 0
128
+ ? `<p class="empty">${EMPTY[view].text}</p>\n<p class="next">${EMPTY[view].next}</p>`
129
+ : `${groupToggle(view, filter, extras, groupRows(rows).some((block) => block.kind === "group"))}${renderRows(rows, extras, ctx.now)}`;
130
+ if (view === "approvals") return `<main>\n${body}\n</main>`;
75
131
  return `<main>
76
- ${renderFilterForm(view, filter)}
77
- ${list}
78
- ${renderPagination(view, page, filter)}
132
+ ${renderFilterForm(view, filter, extras)}
133
+ ${body}
134
+ ${renderPagination(view, page, filter, extras)}
79
135
  </main>`;
80
136
  }
81
137
 
@@ -92,6 +148,8 @@ export interface RenderOptions {
92
148
  audit?: readonly AuditEntry[] | null;
93
149
  /** 列表筛选;不传按默认(全部技能 / 全部状态 / 最近 7 天 / 第 1 页) */
94
150
  filter?: ListFilter;
151
+ /** 三个显式开关(自动刷新 / 分组展开);不传就是默认(不刷新、展开) */
152
+ extras?: ListExtras;
95
153
  /** 现时(单测注入固定时钟) */
96
154
  now?: number;
97
155
  }
@@ -102,23 +160,24 @@ export function renderPage(state: WebState, view: WebView, options: RenderOption
102
160
  const now = options.now ?? Date.now();
103
161
  // 渲染层不自带筛选:不传就是「全部、第 1 页」;产品默认的 7 天由 HTTP 层(parseListQuery)给
104
162
  const filter = options.filter ?? NO_FILTER;
163
+ const extras = options.extras ?? DEFAULT_EXTRAS;
164
+ // `live` 只在待处理页有意义:别的页数据不会自己变,给了也忽略(与 parseListExtras 同一口径)
165
+ const live = extras.live && view === "pending";
105
166
  const page = buildViewPage(state, view, filter, now);
106
- const ctx: RowContext = { csrf, writable, view };
107
- // 只给「待处理」加 5 秒自刷新:等待时人会停留,看档案 / 历史时刷新只会把页面跳回去;
108
- // 待审批页有按钮,刷新会打断正在点的动作,同样不加。
109
- // 带筛选时刷新必须带上同样的筛选参数,否则挑完技能看着看着就被弹回全部 —— 没筛时保持最短 URL。
110
- // 渲染层不传 filter(= 全量、第 1 页)与默认筛选(7d、第 1 页且没挑技能/状态)都用最短 URL
111
- const plainRefresh = options.filter === undefined || isDefaultFilter(filter);
112
- const refresh =
113
- view === "pending"
114
- ? `<meta http-equiv="refresh" content="5; url=${plainRefresh ? "?view=pending" : escapeHtml(pageHref(view, filter, page.page))}">\n`
115
- : "";
167
+ const ctx: RowContext = { csrf, writable, view, now };
168
+ const here = listHref(view, filter, page.page, extras);
169
+ // 只有 `&live=1` 的待处理页才 5 秒重载:别的页数据不会自己变,重载只会丢展开态与滚动位置
170
+ const refresh = live ? `<meta http-equiv="refresh" content="5; url=${escapeHtml(here)}">\n` : "";
171
+ const liveNote = live
172
+ ? renderLiveNote(listHref(view, filter, page.page, { ...extras, live: false }))
173
+ : "";
116
174
  const notice = options.notice
117
- ? `<p class="notice${options.notice.kind === "err" ? " err" : ""}">${escapeHtml(options.notice.text)}</p>\n`
175
+ ? `<p class="notice${options.notice.kind === "err" ? " err" : ""}">${escapeHtml(options.notice.text)}</p>`
118
176
  : "";
119
177
  const top = [
120
- options.controls ? renderControlBar(options.controls, { view, now, csrf, canWrite: writable }) : "",
121
- notice.trimEnd(),
178
+ options.controls ? renderControlBar(options.controls, { view, csrf, canWrite: writable }) : "",
179
+ liveNote,
180
+ notice,
122
181
  ]
123
182
  .filter((part) => part.length > 0)
124
183
  .map((part) => `${part}\n`)
@@ -129,8 +188,11 @@ export function renderPage(state: WebState, view: WebView, options: RenderOption
129
188
  title: VIEW_LABEL[view],
130
189
  active: view,
131
190
  head: refresh,
191
+ meta: options.controls
192
+ ? `${renderUpdatedAt(now, here)} · ${escapeHtml(activityText(options.controls.lastActivityAt, now))}`
193
+ : renderUpdatedAt(now, here),
132
194
  top,
133
- body: listSection(view, page, filter, ctx),
195
+ body: listSection(view, page, filter, extras, ctx),
134
196
  foot: `${audit}${footer(view, writable)}`,
135
197
  });
136
198
  }
@@ -8,6 +8,7 @@ import { readVerify } from "../apply.ts";
8
8
  import type { RunSummary } from "../runs.ts";
9
9
  import { readRunError } from "../stale.ts";
10
10
  import { asNumber, asRecord, asText, readJson, reasonLabel } from "./json-read.ts";
11
+ import { sessionFromRecord, type SessionRef } from "./run-session.ts";
11
12
 
12
13
  /** 单条报告里最多列几个技能:全量列会把 details 撑成第二份 findings */
13
14
  export const MAX_FINDING_ROWS = 5;
@@ -22,6 +23,8 @@ export interface RunReport {
22
23
  runId: string;
23
24
  status: string;
24
25
  trigger: string;
26
+ /** 这条 run 是哪个会话跑出来的:行上「会话」列要用(缺 = NO_SESSION) */
27
+ session: SessionRef;
25
28
  startedAt: number | null;
26
29
  findings: { total: number; rows: FindingRow[] };
27
30
  proposal: { type: string; skill: string; added: number; removed: number } | null;
@@ -84,10 +87,10 @@ function readVerifyGates(runDir: string): RunReport["verify"] {
84
87
  };
85
88
  }
86
89
 
87
- /** 触发者只在 run.json 里(listRuns 的摘要不带它):单独读一次,坏记录当 unknown */
88
- function readTrigger(runDir: string): string {
90
+ /** 触发者与会话只在 run.json 里(listRuns 的摘要不带 trigger):一次读出两样,坏记录当 unknown / 空 */
91
+ function readRunMeta(runDir: string): { trigger: string; session: SessionRef } {
89
92
  const raw = readJson<Record<string, unknown>>(path.join(runDir, "run.json"));
90
- return asText(raw?.trigger, "unknown");
93
+ return { trigger: asText(raw?.trigger, "unknown"), session: sessionFromRecord(raw) };
91
94
  }
92
95
 
93
96
  /** runs/ 下的目录名(不看 run.json,坏掉的目录也要能被 reports 的真实判据「有没有 01-findings」筛) */
@@ -117,6 +120,7 @@ export function buildReports(evolutionDir: string, runs: readonly RunSummary[]):
117
120
  const summaries = new Map(runs.map((run) => [run.runId, run]));
118
121
  const reports: RunReport[] = [];
119
122
  for (const { runId, runDir } of runDirs(evolutionDir)) {
123
+ const meta = readRunMeta(runDir);
120
124
  const findings = readFindings(runDir);
121
125
  if (!findings) {
122
126
  // 失败也要有一行档案:每条 run 都能在报告里找到,不能因为没产出就从历史里消失
@@ -129,7 +133,8 @@ export function buildReports(evolutionDir: string, runs: readonly RunSummary[]):
129
133
  reports.push({
130
134
  runId,
131
135
  status: "stale",
132
- trigger: readTrigger(runDir),
136
+ trigger: meta.trigger,
137
+ session: meta.session,
133
138
  startedAt: summary?.dispatchedAt ?? asNumber(raw?.dispatchedAt),
134
139
  findings: { total: 0, rows: [] },
135
140
  proposal: null,
@@ -147,7 +152,8 @@ export function buildReports(evolutionDir: string, runs: readonly RunSummary[]):
147
152
  reports.push({
148
153
  runId,
149
154
  status: summaries.get(runId)?.status ?? "unknown",
150
- trigger: readTrigger(runDir),
155
+ trigger: meta.trigger,
156
+ session: meta.session,
151
157
  startedAt: summaries.get(runId)?.dispatchedAt ?? null,
152
158
  findings,
153
159
  proposal: proposal ? { type: proposal.type, skill: proposal.skill, added: proposal.added, removed: proposal.removed } : null,
@@ -53,6 +53,10 @@ export interface RunDetail {
53
53
  runId: string;
54
54
  status: string;
55
55
  trigger: string;
56
+ /** pi sessionId(这轮复盘是哪个会话跑出来的);老 run.json 可能没有 = null */
57
+ sessionId: string | null;
58
+ /** 会话原文 .jsonl 的绝对路径;没有 = null */
59
+ sessionFile: string | null;
56
60
  /** 提案目标技能;没有提案时为空串 */
57
61
  skill: string;
58
62
  startedAt: number | null;
@@ -201,6 +205,8 @@ export function readRunDetail(evolutionDir: string, runId: string): RunDetail |
201
205
  runId,
202
206
  status: asText(record?.status, "(无 run.json)"),
203
207
  trigger: asText(record?.trigger, "—"),
208
+ sessionId: asText(record?.sessionId) || null,
209
+ sessionFile: asText(record?.sessionFile) || null,
204
210
  skill,
205
211
  startedAt: asNumber(record?.dispatchedAt),
206
212
  endedAt: stamps.length === 0 ? null : Math.max(...stamps),
@@ -0,0 +1,62 @@
1
+ /**
2
+ * run ↔ 会话的对应关系:面板每一行都要能回答「这条复盘是哪个会话跑出来的」。
3
+ *
4
+ * 为什么需要(用户 2026-11-08:「面板展示的也应该是 pi sessionId 否则我都不知道怎么看对应会话」):
5
+ * 原来行上只有 `runId`(会话哈希8 + 时间戳),对着 TUI 里的 sessionId 完全对不上号,
6
+ * 想回去看那段对话只能自己到 `~/.pi/agent/sessions/` 里翻。
7
+ *
8
+ * 纪律与 data.ts 一致:只读、坏记录当空(`sessionId` 缺失就当 null,绝不因为缺字段让整页 500)。
9
+ * 本文件只管**数据**,链接与列渲染在 render-rows.ts(渲染层)。
10
+ */
11
+ import * as path from "node:path";
12
+ import type { RunSummary } from "../runs.ts";
13
+ import { asText, readJson } from "./json-read.ts";
14
+
15
+ export interface SessionRef {
16
+ /** pi 的 sessionId(UUID),缺失 = null */
17
+ sessionId: string | null;
18
+ /** 会话原文 .jsonl 的绝对路径(pi 写 run.json 时就带上了),缺失 = null */
19
+ sessionFile: string | null;
20
+ }
21
+
22
+ export const NO_SESSION: SessionRef = { sessionId: null, sessionFile: null };
23
+
24
+ /** 从已解析的 run.json 里取会话信息(空白串当缺失:不能让它变成一个空链接) */
25
+ export function sessionFromRecord(raw: unknown): SessionRef {
26
+ if (typeof raw !== "object" || raw === null) return NO_SESSION;
27
+ const record = raw as Record<string, unknown>;
28
+ const sessionId = asText(record.sessionId).trim();
29
+ const sessionFile = asText(record.sessionFile).trim();
30
+ return {
31
+ sessionId: sessionId.length > 0 ? sessionId : null,
32
+ sessionFile: sessionFile.length > 0 ? sessionFile : null,
33
+ };
34
+ }
35
+
36
+ /** 读一条 run 的会话信息;run.json 读不到 / 坏掉都返回 NO_SESSION */
37
+ export function readRunSession(runDir: string): SessionRef {
38
+ return sessionFromRecord(readJson<Record<string, unknown>>(path.join(runDir, "run.json")));
39
+ }
40
+
41
+ /** RunSummary → 会话信息(列表页「会话」列的数据源) */
42
+ export function sessionOfRun(run: Pick<RunSummary, "sessionId" | "sessionFile">): SessionRef {
43
+ return { sessionId: run.sessionId.length > 0 ? run.sessionId : null, sessionFile: run.sessionFile };
44
+ }
45
+
46
+ /** runId → 会话信息:队列条目没有会话字段,只能按 runId 回推到 runs 里查 */
47
+ export function sessionIndex(runs: readonly Pick<RunSummary, "runId" | "sessionId" | "sessionFile">[]): Map<string, SessionRef> {
48
+ return new Map(runs.map((run) => [run.runId, sessionOfRun(run)]));
49
+ }
50
+
51
+ /** 队列条目 → 会话信息;索引里没有它就现读一次 run.json(读不到 = NO_SESSION,链接不渲染) */
52
+ export function sessionForRun(runId: string, index: Map<string, SessionRef>, evolutionDir: string): SessionRef {
53
+ return index.get(runId) ?? readRunSession(path.join(evolutionDir, "runs", runId));
54
+ }
55
+
56
+ /** `?view=session` 页的链接:sessionId 必有,文件路径可缺(缺了由服务端反查) */
57
+ export function sessionHref(session: SessionRef): string | null {
58
+ if (session.sessionId === null) return null;
59
+ const query = new URLSearchParams({ view: "session", sessionId: session.sessionId });
60
+ if (session.sessionFile !== null) query.set("file", session.sessionFile);
61
+ return `/?${query.toString()}`;
62
+ }
@@ -22,12 +22,15 @@ import { collectControls } from "./activity.ts";
22
22
  import { readAudit } from "./audit-log.ts";
23
23
  import { collectWebState } from "./data.ts";
24
24
  import { parseListQuery, parsePanelView } from "./filter.ts";
25
+ import { parseListExtras } from "./list-query.ts";
25
26
  import { HTML_TYPE, MAX_BODY_BYTES, TEXT_TYPE, sendText } from "./http.ts";
26
27
  import { clearWebPid, isAlive, readWebPid, writeWebPid } from "./panel-pid.ts";
27
28
  import { renderPage } from "./render.ts";
28
29
  import { renderRunPage } from "./render-run.ts";
30
+ import { renderSessionPage } from "./render-session.ts";
29
31
  import { renderSkillPage, renderSkillsPage } from "./render-skills.ts";
30
32
  import { handleDiff, readRunDetail } from "./run-detail.ts";
33
+ import { readSessionSource } from "./session-file.ts";
31
34
  import { parseDays, readSkillDetail, readSkills } from "./skills.ts";
32
35
 
33
36
  /** 默认端口:7788 是面板约定值(TUI 提示与文档里都写这个),改它得同时改文档 */
@@ -77,8 +80,9 @@ interface GetResult {
77
80
  /**
78
81
  * 读盘渲染:控制条、最近操作、筛选都在这里一次性读好传给渲染层(渲染层不碰磁盘)。
79
82
  *
80
- * 四类页面:`?view=run&runId=`(run 详情)/ `?view=skills`(技能管理)/ `?view=skill&name=`(技能详情)/ 五个列表页。
83
+ * 四类页面:`?view=run&runId=`(run 详情)/ `?view=session&sessionId=`(会话详情)/ `?view=skills`(技能管理)/ `?view=skill&name=`(技能详情)/ 五个列表页。
81
84
  * 详情页的 runId 不合法或目录不存在一律 404 —— 靠 resolveRunDir 的目录校验,不靠字符串过滤。
85
+ * 会话页不走 404:会话文件可能已不在本机,那种情况页面要说人话(见 render-session.ts)。
82
86
  */
83
87
  function renderGet(ctx: RouteContext, url: URL): GetResult {
84
88
  const err = url.searchParams.get("err");
@@ -88,6 +92,20 @@ function renderGet(ctx: RouteContext, url: URL): GetResult {
88
92
  const state = collectWebState({ evolutionDir: ctx.evolutionDir });
89
93
  const view = parsePanelView(url.searchParams.get("view"));
90
94
  const days = parseDays(url.searchParams.get("days"));
95
+ if (view === "session") {
96
+ const sessionId = url.searchParams.get("sessionId") ?? "";
97
+ const source = readSessionSource({
98
+ evolutionDir: ctx.evolutionDir,
99
+ sessionId,
100
+ ...(url.searchParams.get("file") !== null ? { file: url.searchParams.get("file") } : {}),
101
+ });
102
+ // `only=user` 在服务端过滤(面板零 JS):列表只渲染用户消息与助手纯文本,工具调用压成一行
103
+ return {
104
+ status: 200,
105
+ body: renderSessionPage(source, { state, onlyUser: url.searchParams.get("only") === "user" }),
106
+ type: HTML_TYPE,
107
+ };
108
+ }
91
109
  if (view === "run") {
92
110
  const runId = url.searchParams.get("runId") ?? "";
93
111
  const detail = readRunDetail(ctx.evolutionDir, runId);
@@ -126,6 +144,8 @@ function renderGet(ctx: RouteContext, url: URL): GetResult {
126
144
  controls,
127
145
  audit: readAudit(ctx.evolutionDir, RECENT_ACTIONS),
128
146
  filter: parseListQuery(url.searchParams),
147
+ // 三个显式开关(自动刷新 / 分组展开 / 只看对话的前两个):`live` 按页面白名单放行,仅待处理页生效
148
+ extras: parseListExtras(url.searchParams, view),
129
149
  }),
130
150
  type: HTML_TYPE,
131
151
  };