@baique/pi-web-sky 0.1.56 → 0.1.57

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 (138) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +5 -5
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/react-loadable-manifest.json +2 -2
  5. package/.next/required-server-files.js +3 -2
  6. package/.next/required-server-files.json +3 -2
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_not-found.html +1 -1
  15. package/.next/server/app/_not-found.rsc +3 -3
  16. package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  18. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/server/app/api/agent/[id]/bash-output/route.js +2 -2
  20. package/.next/server/app/api/agent/[id]/events/route.js +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js +1 -1
  22. package/.next/server/app/api/agent/new/route.js +1 -1
  23. package/.next/server/app/api/agent/running/route.js +1 -1
  24. package/.next/server/app/api/app-update/route.js +1 -1
  25. package/.next/server/app/api/board-assets/[...path]/route.js +1 -1
  26. package/.next/server/app/api/board-assets/route.js +1 -1
  27. package/.next/server/app/api/boards/[id]/add-session/route.js +1 -1
  28. package/.next/server/app/api/boards/[id]/reconcile/route.js +1 -1
  29. package/.next/server/app/api/boards/[id]/route.js +1 -1
  30. package/.next/server/app/api/boards/reorder/route.js +1 -1
  31. package/.next/server/app/api/boards/route.js +1 -1
  32. package/.next/server/app/api/cwd/validate/route.js +1 -1
  33. package/.next/server/app/api/default-cwd/route.js +1 -1
  34. package/.next/server/app/api/file-index/route.js +1 -1
  35. package/.next/server/app/api/files/[...path]/route.js +2 -2
  36. package/.next/server/app/api/mcp/route.js +1 -1
  37. package/.next/server/app/api/models/route.js +1 -1
  38. package/.next/server/app/api/plugins/route.js +1 -1
  39. package/.next/server/app/api/project-trust/route.js +1 -1
  40. package/.next/server/app/api/search/route.js +2 -2
  41. package/.next/server/app/api/sessions/[id]/auto-name/route.js +2 -2
  42. package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
  43. package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +2 -2
  44. package/.next/server/app/api/sessions/[id]/export/route.js +2 -2
  45. package/.next/server/app/api/sessions/[id]/minimap/route.js +1 -1
  46. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  47. package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
  48. package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
  49. package/.next/server/app/api/sessions/route.js +1 -1
  50. package/.next/server/app/api/sessions/summary/route.js +1 -1
  51. package/.next/server/app/api/skills/install/route.js +1 -1
  52. package/.next/server/app/api/subagents/[id]/route.js +1 -1
  53. package/.next/server/app/api/subagents/profiles/route.js +1 -1
  54. package/.next/server/app/api/task-card-questions/[id]/answer/route.js +1 -1
  55. package/.next/server/app/api/task-card-questions/route.js +1 -1
  56. package/.next/server/app/api/task-cards/[id]/route.js +1 -1
  57. package/.next/server/app/api/task-cards/route.js +1 -1
  58. package/.next/server/app/api/task-cards/unbind/route.js +1 -1
  59. package/.next/server/app/api/task-scheduler/status/route.js +1 -1
  60. package/.next/server/app/api/tasks/[id]/assign-session/route.js +2 -12
  61. package/.next/server/app/api/tasks/[id]/board/route.js +1 -1
  62. package/.next/server/app/api/tasks/[id]/route.js +1 -1
  63. package/.next/server/app/api/tasks/reorder/route.js +2 -12
  64. package/.next/server/app/api/tasks/route.js +2 -12
  65. package/.next/server/app/api/worktrees/route.js +1 -1
  66. package/.next/server/app/index.html +1 -1
  67. package/.next/server/app/index.rsc +5 -5
  68. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  69. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  70. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  71. package/.next/server/app/page.js +10 -10
  72. package/.next/server/app/page_client-reference-manifest.js +1 -1
  73. package/.next/server/app-paths-manifest.json +5 -5
  74. package/.next/server/chunks/1937.js +14 -13
  75. package/.next/server/chunks/2524.js +2 -2
  76. package/.next/server/chunks/3500.js +1 -1
  77. package/.next/server/chunks/402.js +21 -10
  78. package/.next/server/chunks/4457.js +3 -13
  79. package/.next/server/chunks/542.js +8 -8
  80. package/.next/server/chunks/5794.js +1 -1
  81. package/.next/server/chunks/5940.js +1 -1
  82. package/.next/server/chunks/6429.js +4 -4
  83. package/.next/server/chunks/6525.js +18 -7
  84. package/.next/server/chunks/6583.js +12 -11
  85. package/.next/server/middleware-build-manifest.js +1 -1
  86. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  87. package/.next/server/pages/404.html +1 -1
  88. package/.next/server/pages/500.html +1 -1
  89. package/.next/static/chunks/752.bafff304e6b00987.js +1 -0
  90. package/.next/static/chunks/8088.39866dc5f1d15868.js +1 -0
  91. package/.next/static/chunks/app/{layout-87087b955e0c9507.js → layout-fae2b226b3c78448.js} +1 -1
  92. package/.next/static/chunks/app/page-d5af3bd535589daf.js +256 -0
  93. package/.next/static/chunks/{webpack-4de8e8dd9f2f3173.js → webpack-1ce35e1249cfd931.js} +1 -1
  94. package/.next/static/css/{c6d32b6031181be7.css → c7d127e07b1a786c.css} +1 -1
  95. package/.next/trace +8 -7
  96. package/.next/trace-build +1 -1
  97. package/lib/board-reconcile.test.mjs +26 -8
  98. package/lib/board-reconcile.ts +3 -1
  99. package/lib/board-store.test.mjs +2 -2
  100. package/lib/ime-guard.test.mjs +52 -0
  101. package/lib/ime-guard.ts +54 -0
  102. package/lib/rpc-manager.activity.test.mjs +223 -0
  103. package/lib/rpc-manager.test.mjs +22 -0
  104. package/lib/rpc-manager.title.test.mjs +152 -0
  105. package/lib/rpc-manager.ts +95 -8
  106. package/lib/session-activity.test.mjs +123 -0
  107. package/lib/session-activity.ts +77 -0
  108. package/lib/session-delete.test.mjs +45 -41
  109. package/lib/session-delete.ts +8 -34
  110. package/lib/session-index-scanner.test.mjs +364 -2
  111. package/lib/session-index-scanner.ts +162 -57
  112. package/lib/session-reader.noscan.test.mjs +371 -0
  113. package/lib/session-reader.project.test.mjs +30 -52
  114. package/lib/session-reader.test.mjs +2 -65
  115. package/lib/session-reader.ts +177 -213
  116. package/lib/session-scanner.test.mjs +151 -38
  117. package/lib/session-scanner.ts +140 -61
  118. package/lib/session-search.ts +6 -5
  119. package/lib/sqlite-db.test.mjs +26 -0
  120. package/lib/sqlite-db.ts +10 -1
  121. package/lib/subagent-runtime.ts +42 -0
  122. package/lib/subagent-session-title.test.mjs +120 -0
  123. package/lib/task-scheduler.test.mjs +27 -0
  124. package/lib/task-scheduler.ts +8 -1
  125. package/lib/task-store.activity.test.mjs +141 -0
  126. package/lib/task-store.meta-row.test.mjs +25 -5
  127. package/lib/task-store.partial-row.test.mjs +198 -0
  128. package/lib/task-store.subtree.test.mjs +172 -0
  129. package/lib/task-store.test.mjs +46 -20
  130. package/lib/task-store.ts +344 -68
  131. package/next.config.ts +3 -1
  132. package/package.json +1 -1
  133. package/.next/static/chunks/752.adce8bc3ebbaf4b6.js +0 -1
  134. package/.next/static/chunks/8088.0cad1ff2e09fd8ff.js +0 -1
  135. package/.next/static/chunks/app/page-efca6bf51a5f8ccb.js +0 -256
  136. /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_buildManifest.js +0 -0
  137. /package/.next/static/{k8ZFqwz7d-bnK0ZiUVgCc → MPBMmQqmDG8zo8tM_bkDy}/_ssgManifest.js +0 -0
  138. /package/lib/{subagent-isolation.test.mjs → worktree-isolation.test.mjs} +0 -0
@@ -6,17 +6,24 @@
6
6
  // CLI 新建),使列表读取退化为纯单表查询。
7
7
  //
8
8
  // 一轮 tick 做的事:
9
- // 1. scanSessionFileMeta():全量 readdir + stat(不读内容),拿磁盘会话全集
10
- // 2. 与 session_meta 现有行 diff:
11
- // - 磁盘有、库无 → 读 header(scanOneSessionHead)→ resolveProject 算
9
+ // 1. scanSessionFileMeta():全量 readdir + stat(不读内容,递归到子目录),拿磁盘会话全集
10
+ // 2. 与 session_meta 现有行 diff(path 是会话与文件的唯一绑定,行匹配 path 优先):
11
+ // - 磁盘有、库无 → 读 header(scanOneSessionHead)→ 用 **header 的 id** 建行
12
+ // (子目录里的 `session.jsonl` 没有文件名 id 可猜)→ resolveProject 算
12
13
  // project_key → INSERT(created/mtime/first_message)
13
14
  // - 磁盘有、库有、mtime 变 → 读 header 首行校验 parent_id(重挂收敛)+ 刷 modified
14
- // - 库有、磁盘无 → 删前磁盘复检后 DELETE(防扫描快照后新建被误删)
15
- // 3. 全程幂等(session_id 主键 upsert 语义),可重复执行。
15
+ // - 库有、磁盘无 → 删前磁盘复检 + 年轻行保护后 DELETE
16
+ // 3. last_reply 回填:`last_reply IS NULL` 的行读一次文件尾(无回复写 '')→ 存量会话的
17
+ // 「最后一条消息」入库存档,读取路径从此不再读文件
18
+ // 4. 归属收敛:子会话继承父会话 task_id(多层脏链一轮收敛)
19
+ // 5. 全程幂等(session_id 主键 upsert 语义),可重复执行。
16
20
  //
17
- // 明确不做:不读文件尾部(不追外部改名——title 由本应用改名写库);不存
18
- // last_reply(列表尾读)。外部改动导致 mtime 变化只刷新排序键;header 首行
19
- // 仅用于 parent_id 校验(成本可忽略,父链以磁盘 header 为权威)。
21
+ // 明确不做:不读文件尾部追外部改名/标题(title 由本应用改名写库);存量 `last_reply`
22
+ // 仅一次性回填(见下)。外部改动导致 mtime 变化只刷新排序键(且单调,不回退);
23
+ // header 首行仅用于 parent_id 校验(成本可忽略,父链以磁盘 header 为权威)。
24
+ //
25
+ // last_reply 三态:NULL = 未回填(读一次文件尾)、'' = 回填过但无回复、文本 = 真实回复。
26
+ // 事件链路(agent_settled)写真实回复,列表/看板卡只读库。
20
27
  //
21
28
  // 调度:startSessionIndexScanner() 在 instrumentation.ts 注册(globalThis
22
29
  // 防热重载重复启动),启动即跑首轮 + 定期续跑;runSessionIndexScan() 提供
@@ -26,11 +33,12 @@ import { existsSync } from "fs";
26
33
  import { getDb } from "./sqlite-db";
27
34
  import { projectIdentityKey } from "./project-identity";
28
35
  import { sessionPathKey } from "./session-path";
29
- import { scanOneSessionHead, scanSessionFileMeta } from "./session-scanner";
36
+ import { scanOneSessionHead, scanSessionFileMeta, readSessionTail, type SessionFileMeta } from "./session-scanner";
30
37
  import { resolveProject } from "./worktree";
31
38
 
32
39
  /** 一轮扫描的变更摘要(日志/测试断言用)。 */
33
40
  export interface SessionIndexScanSummary {
41
+ /** 本轮在磁盘上解析出的会话数(非会话 jsonl 不计;同 id 多副本只算一个)。 */
34
42
  scanned: number;
35
43
  inserted: number;
36
44
  updated: number;
@@ -40,14 +48,24 @@ export interface SessionIndexScanSummary {
40
48
  /** 默认扫描周期:30s(外部/CLI 新建会话最长等一个周期进列表)。 */
41
49
  export const SESSION_INDEX_SCAN_INTERVAL_MS = 30_000;
42
50
 
51
+ /** 归属收敛单轮扫描最多继承几层(单条 UPDATE 一次只能修一层,见 runSessionIndexScan;
52
+ * 正常链路最多 2–3 层,8 是防病态深链/环的上限)。 */
53
+ const INHERIT_MAX_PASSES = 8;
54
+
55
+ /** 删除分支的「年轻行保护」窗口:行 `updated` 在最近 60s 内则不删(见 runSessionIndexScan)。 */
56
+ const YOUNG_ROW_PROTECT_MS = 60_000;
57
+
43
58
  /**
44
59
  * 单文件立即索引:新会话文件已落盘但等不及后台扫描时调用(如 fork_branch 创建
45
60
  * 的引用分支,需立刻出现在会话列表)。幂等 upsert,parent 由调用方显式指定
46
61
  * (磁盘 header 的父路径反查需要全量 path 映射,单文件场景不划算)。
62
+ * `taskId` 为会话归属(调用方传入源会话的 task_id):归属继承必须在建行时完成,
63
+ * 否则会话会先以临时会话现身聊天区(V1 两区重复)。ON CONFLICT 不覆盖已有归属。
47
64
  */
48
65
  export async function indexSessionFileNow(
49
66
  filePath: string,
50
67
  parentSessionId: string | null,
68
+ taskId: string | null = null,
51
69
  ): Promise<void> {
52
70
  const head = scanOneSessionHead(filePath);
53
71
  if (!head) return;
@@ -56,18 +74,20 @@ export async function indexSessionFileNow(
56
74
  .prepare(
57
75
  `INSERT INTO session_meta
58
76
  (session_id, task_id, updated, pinned, path, cwd, project_key, title, first_message, parent_id, created, modified)
59
- VALUES (?, NULL, ?, 0, ?, ?, ?, NULL, ?, ?, ?, ?)
77
+ VALUES (?, ?, ?, 0, ?, ?, ?, NULL, ?, ?, ?, ?)
60
78
  ON CONFLICT(session_id) DO UPDATE SET
61
79
  path = excluded.path,
62
80
  cwd = excluded.cwd,
63
81
  project_key = excluded.project_key,
64
- first_message = excluded.first_message,
82
+ -- first_message 只补空:调用方不传(NULL)时不再把已有的首条消息擦掉。
83
+ first_message = COALESCE(session_meta.first_message, excluded.first_message),
65
84
  parent_id = excluded.parent_id,
66
85
  created = excluded.created,
67
86
  modified = excluded.modified`,
68
87
  )
69
88
  .run(
70
89
  head.id,
90
+ taskId,
71
91
  Date.now(),
72
92
  filePath,
73
93
  head.cwd,
@@ -83,32 +103,60 @@ export async function indexSessionFileNow(
83
103
  export async function runSessionIndexScan(
84
104
  sessionsDir?: string,
85
105
  ): Promise<SessionIndexScanSummary> {
86
- // 磁盘同 id 可能有多个历史文件(pi 整文件重写留旧副本)——索引只认每个
87
- // id 的「最新 mtime 文件」,旧副本忽略(path 指向最新,排序用最新 mtime)。
106
+ // 磁盘 → 会话 id:path 优先(path 是会话与文件的唯一绑定),header 是 id 的权威。
107
+ // 同 id 多文件(pi 整文件重写留旧副本、子目录里同名副本)只认最新 mtime 文件——
108
+ // 与「path 指向最新」一致。注意不能按文件名提示归组:子目录里的
109
+ // `session.jsonl`、`*_scout_0_transcript.jsonl` 末段提示会撞名(都叫
110
+ // session/transcript),按提示去重会把不同会话当成同一个丢掉。
88
111
  const allDiskFiles = await scanSessionFileMeta(sessionsDir);
89
- const newestByFile = new Map<string, { path: string; id: string; modified: Date }>();
90
- for (const f of allDiskFiles) {
91
- const cur = newestByFile.get(f.id);
92
- if (!cur || f.modified.getTime() > cur.modified.getTime()) newestByFile.set(f.id, f);
93
- }
94
- const diskFiles = [...newestByFile.values()];
95
- const summary: SessionIndexScanSummary = { scanned: diskFiles.length, inserted: 0, updated: 0, deleted: 0 };
96
112
 
97
113
  const db = getDb();
98
- const selectAll = db.prepare("SELECT session_id, modified, path, parent_id, first_message FROM session_meta").all() as Array<{
114
+ const selectAll = db.prepare("SELECT session_id, modified, updated, path, parent_id, first_message FROM session_meta").all() as Array<{
99
115
  session_id: string;
100
116
  modified: number | null;
117
+ updated: number;
101
118
  path: string | null;
102
119
  parent_id: string | null;
103
120
  first_message: string | null;
104
121
  }>;
105
- const dbRows = new Map(selectAll.map((r) => [r.session_id, { modified: r.modified, path: r.path, parent_id: r.parent_id, first_message: r.first_message }]));
122
+ const rowById = new Map(selectAll.map((r) => [r.session_id, r]));
123
+ const rowByPath = new Map(
124
+ selectAll.filter((r) => r.path).map((r) => [sessionPathKey(r.path as string), r.session_id]),
125
+ );
106
126
 
127
+ type SessionHead = NonNullable<ReturnType<typeof scanOneSessionHead>>;
128
+ const resolved = new Map<string, { file: SessionFileMeta; head?: SessionHead }>();
129
+ // 本轮在磁盘上见到过的会话 id——删除分支的反面。
107
130
  const seenOnDisk = new Set<string>();
131
+ const keepNewest = (sessionId: string, file: SessionFileMeta, head?: SessionHead): void => {
132
+ const cur = resolved.get(sessionId);
133
+ if (cur && cur.file.modified.getTime() >= file.modified.getTime()) return;
134
+ resolved.set(sessionId, { file, head });
135
+ };
136
+
137
+ for (const file of allDiskFiles) {
138
+ // 1) path 已绑库行 → 直接用库行 id,不读文件(header 不再参与:行已经指向本文件)。
139
+ const boundRowId = rowByPath.get(sessionPathKey(file.path));
140
+ if (boundRowId !== undefined) {
141
+ keepNewest(boundRowId, file);
142
+ continue;
143
+ }
144
+ // 2) path 未绑库行(新文件 / 被移动的文件 / 同 id 旧副本)→ 读 header,id 以 header 为准。
145
+ const head = scanOneSessionHead(file.path);
146
+ if (head) {
147
+ keepNewest(head.id, file, head);
148
+ continue;
149
+ }
150
+ // 3) header 读不出会话(非会话 jsonl、写到一半的坏行)→ 不建行也不刷列;
151
+ // 只有文件名提示能对上库行时才算「该会话的文件还在磁盘」,免得读不出内容就删行。
152
+ if (file.id && rowById.has(file.id)) seenOnDisk.add(file.id);
153
+ }
108
154
 
109
- // 磁盘全量 path→id 映射(parent 路径反查父会话 id;同目录多文件共用)。
155
+ const summary: SessionIndexScanSummary = { scanned: resolved.size, inserted: 0, updated: 0, deleted: 0 };
156
+
157
+ // 磁盘全量 path→id 映射(parent 路径反查父会话 id;含子目录里的文件)。
110
158
  const pathToId = new Map<string, string>();
111
- for (const f of diskFiles) pathToId.set(sessionPathKey(f.path), f.id);
159
+ for (const [sessionId, entry] of resolved) pathToId.set(sessionPathKey(entry.file.path), sessionId);
112
160
 
113
161
  // 同 cwd 目录的 project_key 共享一次 resolve(缓存 promise,防重复 git 调用)。
114
162
  const cwdToProjectKey = new Map<string, Promise<string>>();
@@ -121,20 +169,29 @@ export async function runSessionIndexScan(
121
169
  path = excluded.path,
122
170
  cwd = excluded.cwd,
123
171
  project_key = excluded.project_key,
124
- first_message = excluded.first_message,
172
+ -- first_message 只补空(COALESCE):库内已有首条消息时不被这次 upsert 的 NULL/旧值擦掉。
173
+ -- 内容派生列的「变化收敛」由 reconcileFromHead 单独负责(走 mtime 前移/欠账分支)。
174
+ first_message = COALESCE(session_meta.first_message, excluded.first_message),
125
175
  parent_id = excluded.parent_id,
126
176
  created = excluded.created,
127
- modified = excluded.modified`,
177
+ -- modified 也单调:path 迁移(文件被移动/重写)时不能用更旧的 mtime 把事件
178
+ -- 链路刚写的「现在就活跃」拉回去(与 updateModifiedStmt 同一约定)。
179
+ modified = CASE
180
+ WHEN session_meta.modified IS NULL OR excluded.modified > session_meta.modified
181
+ THEN excluded.modified ELSE session_meta.modified END`,
182
+ );
183
+ // modified 单调:事件链路刚写的「现在就活跃」不能被下一轮扫描用更旧的 mtime
184
+ // 拉回去(列表读取会触发懒初始化扫描 → 这是可达路径)。
185
+ const updateModifiedStmt = db.prepare(
186
+ "UPDATE session_meta SET modified = ? WHERE session_id = ? AND (modified IS NULL OR modified < ?)",
128
187
  );
129
- const updateModifiedStmt = db.prepare("UPDATE session_meta SET modified = ? WHERE session_id = ?");
130
188
  const updateParentStmt = db.prepare("UPDATE session_meta SET parent_id = ? WHERE session_id = ?");
131
189
  const updateFirstMessageStmt = db.prepare("UPDATE session_meta SET first_message = ? WHERE session_id = ?");
132
190
  const deleteStmt = db.prepare("DELETE FROM session_meta WHERE session_id = ?");
133
191
 
134
- // 读 header + resolve project + upsert(新文件或老行补全索引列共用)。
135
- const upsertFromDisk = async (file: { path: string; id: string; modified: Date }): Promise<void> => {
136
- const head = scanOneSessionHead(file.path);
137
- if (!head) return;
192
+ // resolve project + upsert(新文件或老行补全索引列共用)。header 由调用方传入:
193
+ // 已读过的不重复读(子目录里的文件每轮都要靠 header 认 id)。
194
+ const upsertFromDisk = async (file: SessionFileMeta, head: SessionHead, sessionId: string): Promise<void> => {
138
195
  let projectPromise = cwdToProjectKey.get(head.cwd);
139
196
  if (!projectPromise) {
140
197
  projectPromise = resolveProject(head.cwd).then((p) => projectIdentityKey(p?.projectRoot ?? head.cwd));
@@ -145,7 +202,7 @@ export async function runSessionIndexScan(
145
202
  ? (pathToId.get(sessionPathKey(head.parentSessionPath)) ?? null)
146
203
  : null;
147
204
  insertStmt.run(
148
- file.id,
205
+ sessionId,
149
206
  Date.now(),
150
207
  file.path,
151
208
  head.cwd,
@@ -161,56 +218,104 @@ export async function runSessionIndexScan(
161
218
  // first_message 是内容派生列:文件写入了首条消息,索引必须跟随——
162
219
  // 否则 persist 建行的会话(first_message 恒 NULL)标题永远冻结。
163
220
  // 返回是否更新了 first_message(供 NULL 欠账补跑计数)。
164
- const reconcileFromHead = (file: { path: string; id: string }, dbRow: { parent_id: string | null; first_message: string | null }): boolean => {
221
+ const reconcileFromHead = (file: SessionFileMeta, dbRow: { parent_id: string | null; first_message: string | null }, sessionId: string): boolean => {
165
222
  const head = scanOneSessionHead(file.path);
166
223
  if (!head) return false;
167
224
  const parentId = head.parentSessionPath
168
225
  ? (pathToId.get(sessionPathKey(head.parentSessionPath)) ?? null)
169
226
  : null;
170
227
  if (parentId !== dbRow.parent_id) {
171
- updateParentStmt.run(parentId, file.id);
228
+ updateParentStmt.run(parentId, sessionId);
172
229
  }
173
230
  if (head.firstMessage && head.firstMessage !== dbRow.first_message) {
174
- updateFirstMessageStmt.run(head.firstMessage, file.id);
231
+ updateFirstMessageStmt.run(head.firstMessage, sessionId);
175
232
  return true;
176
233
  }
177
234
  return false;
178
235
  };
179
236
 
180
- for (const file of diskFiles) {
181
- seenOnDisk.add(file.id);
182
- const dbRow = dbRows.get(file.id);
237
+ for (const [sessionId, entry] of resolved) {
238
+ const { file } = entry;
239
+ seenOnDisk.add(sessionId);
240
+ const dbRow = rowById.get(sessionId);
183
241
  if (dbRow === undefined || !dbRow.path || dbRow.path !== file.path) {
184
- // 磁盘有、库无 → 建行;库行 path 空(T1 前遗留归属行)或指向旧副本 →
185
- // 用磁盘最新文件 upsert 修正(path 指向最新,不保留旧副本 path)。
186
- await upsertFromDisk(file);
242
+ // 磁盘有、库无 → 建行;库行 path 空(pin/rename 建的行)/ 指向旧副本 / 文件被移动
243
+ // → 用磁盘最新文件 upsert 修正(path 指向最新,不保留旧 path)。
244
+ const head = entry.head ?? scanOneSessionHead(file.path);
245
+ if (!head) continue; // header 读不出 → 不写半行,下一轮再看
246
+ await upsertFromDisk(file, head, sessionId);
187
247
  summary.inserted += 1;
188
- } else if (dbRow.modified === null || dbRow.modified !== file.modified.getTime()) {
189
- // mtime 变化:刷新排序键;同时收敛派生列——parent 链(删除会话级联
190
- // 重挂/外部重挂会改 header.parentSession)与 first_message(内容写入
248
+ } else if (dbRow.modified === null || file.modified.getTime() > dbRow.modified) {
249
+ // mtime 前移(真的又活跃了):刷新排序键;同时收敛派生列——parent 链(删除会话
250
+ // 级联重挂/外部重挂会改 header.parentSession)与 first_message(内容写入
191
251
  // 后标题必须跟随)。磁盘 header 是父链唯一权威,文件头是标题事实源。
192
- reconcileFromHead(file, dbRow);
193
- updateModifiedStmt.run(file.modified.getTime(), file.id);
252
+ // 只认「前移」:modified 已被事件链路写成 now() 时,库里值 >= mtime(文件头/尾巴
253
+ // 还没落盘),若用 !== 判等,每轮扫描都会多读一次 header 并记一次幻影 updated,
254
+ // 「幂等扫描」不再成立(mtime 真变新的场景由下面的 > 覆盖,不受影响)。
255
+ reconcileFromHead(file, dbRow, sessionId);
256
+ updateModifiedStmt.run(file.modified.getTime(), sessionId, file.modified.getTime());
194
257
  summary.updated += 1;
195
258
  } else if (!dbRow.first_message) {
196
259
  // first_message 欠账补跑:persist 建行的老会话(first_message 恒 NULL)
197
260
  // 在 mtime 已同步后仍需回填一次;文件已有首条消息则写入,否则保持 NULL。
198
- if (reconcileFromHead(file, dbRow)) {
261
+ if (reconcileFromHead(file, dbRow, sessionId)) {
199
262
  summary.updated += 1;
200
263
  }
201
264
  }
202
265
  }
203
266
 
204
267
  // 库有、磁盘无 → 删除(会话被外部/本应用删除)。
205
- for (const sessionId of dbRows.keys()) {
206
- if (!seenOnDisk.has(sessionId)) {
207
- // 删前以磁盘复检:扫描快照后新建/移动的会话(persist 建行 + 文件落盘)
208
- // 可能不在本轮快照里,直接删会把刚建的行误删成“列表消失”。path 指向
209
- // 存在的文件 → 跳过(下一轮扫描会正常 diff)。
210
- const row = dbRows.get(sessionId);
211
- if (row?.path && existsSync(row.path)) continue;
212
- deleteStmt.run(sessionId);
213
- summary.deleted += 1;
268
+ const deleteCheckedAt = Date.now();
269
+ for (const row of selectAll) {
270
+ if (seenOnDisk.has(row.session_id)) continue;
271
+ // 删前以磁盘复检:扫描快照后新建/移动的会话(persist 建行 + 文件落盘)
272
+ // 可能不在本轮快照里,直接删会把刚建的行误删成“列表消失”。path 指向
273
+ // 存在的文件 → 跳过(下一轮扫描会正常 diff)。
274
+ if (row.path && existsSync(row.path)) continue;
275
+ // 年轻行保护:内置 subagent 等会话是「先建行、SDK 惰性 flush 才落盘」,行刚建
276
+ // 完时磁盘上还没有对应文件。这种行删了会在下一轮重新建行 → 列表闪断 + 归属丢失。
277
+ // 只跳过、下一轮再判(外部删除的新会话最多晚 60s 从列表消失)。
278
+ // 前提:扫描器的 upsert 只在**新建行**时写 updated;ON CONFLICT 分支不写 updated
279
+ // (顶上的 INSERT 列了 updated,但 DO UPDATE SET 列表里没有它)—— 若把
280
+ // updated = excluded.updated 加进冲突分支,每轮扫描都会把自己的行重新变
281
+ // 「年轻」,外部删除就永不收敛。
282
+ if (deleteCheckedAt - row.updated < YOUNG_ROW_PROTECT_MS) continue;
283
+ deleteStmt.run(row.session_id);
284
+ summary.deleted += 1;
285
+ }
286
+
287
+ // 归属收敛:子会话 task_id 为空而父会话有归属 → 继承父(自愈旧数据与写库失败的窗口;
288
+ // 「归属按子树存」是本应用的约定,用户单独把子会话移出任务的状态不会出现)。
289
+ // 单条 UPDATE 每轮只能修一层(SQLite 用语句开始时的快照求值,实测 A(T)←B←C 只修 B),
290
+ // 故循环到无变化为止,使多层脏链在一轮扫描内整体收敛。
291
+ const inheritTaskStmt = db.prepare(
292
+ `UPDATE session_meta AS c
293
+ SET task_id = (SELECT p.task_id FROM session_meta AS p WHERE p.session_id = c.parent_id)
294
+ WHERE c.task_id IS NULL
295
+ AND c.parent_id IS NOT NULL
296
+ AND EXISTS (SELECT 1 FROM session_meta AS p WHERE p.session_id = c.parent_id AND p.task_id IS NOT NULL)`,
297
+ );
298
+ for (let pass = 0; pass < INHERIT_MAX_PASSES; pass += 1) {
299
+ const changes = Number(inheritTaskStmt.run().changes); // changes 类型为 number|bigint
300
+ if (changes === 0) break;
301
+ summary.updated += changes;
302
+ }
303
+
304
+ // last_reply 一次性回填(存量会话):三态里只有 NULL 才读文件尾——读一次拿到
305
+ // 「最后一条 assistant 回复」(无回复写 ''),之后读取路径(列表/看板卡)再也不摸盘。
306
+ // 文件不可读/不存在 → 保持 NULL,下一轮再试(与删除分支的磁盘复检同思路)。
307
+ const pendingReplyRows = db
308
+ .prepare("SELECT session_id, path FROM session_meta WHERE last_reply IS NULL AND path IS NOT NULL")
309
+ .all() as Array<{ session_id: string; path: string }>;
310
+ if (pendingReplyRows.length > 0) {
311
+ const backfillReplyStmt = db.prepare(
312
+ "UPDATE session_meta SET last_reply = ? WHERE session_id = ? AND last_reply IS NULL",
313
+ );
314
+ for (const row of pendingReplyRows) {
315
+ const tail = readSessionTail(row.path);
316
+ if (!tail) continue;
317
+ backfillReplyStmt.run(tail.lastReply || "", row.session_id);
318
+ summary.updated += 1;
214
319
  }
215
320
  }
216
321
 
@@ -265,7 +370,7 @@ export function startSessionIndexScanner(): void {
265
370
  let tickInFlight = false;
266
371
  const firstScanResult = runSessionIndexScan()
267
372
  .then((summary) => {
268
- console.log(`[pi-web] session index scan: +${summary.inserted} ~${summary.updated} -${summary.deleted} (${summary.scanned} files)`);
373
+ console.log(`[pi-web] session index scan: +${summary.inserted} ~${summary.updated} -${summary.deleted} (${summary.scanned} sessions)`);
269
374
  return { ok: true as const };
270
375
  })
271
376
  .catch((e) => {