@baique/pi-web-sky 0.1.42 → 0.1.44

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 (295) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +13 -13
  3. package/.next/build-manifest.json +4 -4
  4. package/.next/prerender-manifest.json +3 -3
  5. package/.next/react-loadable-manifest.json +3 -3
  6. package/.next/required-server-files.js +5 -5
  7. package/.next/required-server-files.json +5 -5
  8. package/.next/server/app/_global-error/page.js +2 -2
  9. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  10. package/.next/server/app/_global-error.html +1 -1
  11. package/.next/server/app/_global-error.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  15. package/.next/server/app/_not-found/page.js +1 -1
  16. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  17. package/.next/server/app/_not-found.html +1 -1
  18. package/.next/server/app/_not-found.rsc +3 -3
  19. package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  20. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  21. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  22. package/.next/server/app/api/agent/[id]/bash-output/route.js +2 -2
  23. package/.next/server/app/api/agent/[id]/bash-output/route_client-reference-manifest.js +1 -1
  24. package/.next/server/app/api/agent/[id]/events/route.js +1 -1
  25. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  26. package/.next/server/app/api/agent/[id]/events/route_client-reference-manifest.js +1 -1
  27. package/.next/server/app/api/agent/[id]/route.js +1 -1
  28. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  29. package/.next/server/app/api/agent/[id]/route_client-reference-manifest.js +1 -1
  30. package/.next/server/app/api/agent/new/route.js +1 -1
  31. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  32. package/.next/server/app/api/agent/new/route_client-reference-manifest.js +1 -1
  33. package/.next/server/app/api/agent/running/route.js +1 -4
  34. package/.next/server/app/api/agent/running/route.js.nft.json +1 -1
  35. package/.next/server/app/api/agent/running/route_client-reference-manifest.js +1 -1
  36. package/.next/server/app/api/app-update/route.js +1 -1
  37. package/.next/server/app/api/app-update/route_client-reference-manifest.js +1 -1
  38. package/.next/server/app/api/auth/all-providers/route.js +1 -1
  39. package/.next/server/app/api/auth/all-providers/route_client-reference-manifest.js +1 -1
  40. package/.next/server/app/api/auth/api-key/[provider]/route.js +1 -1
  41. package/.next/server/app/api/auth/api-key/[provider]/route_client-reference-manifest.js +1 -1
  42. package/.next/server/app/api/auth/login/[provider]/route.js +2 -2
  43. package/.next/server/app/api/auth/login/[provider]/route_client-reference-manifest.js +1 -1
  44. package/.next/server/app/api/auth/logout/[provider]/route.js +1 -1
  45. package/.next/server/app/api/auth/logout/[provider]/route_client-reference-manifest.js +1 -1
  46. package/.next/server/app/api/auth/providers/route.js +1 -1
  47. package/.next/server/app/api/auth/providers/route_client-reference-manifest.js +1 -1
  48. package/.next/server/app/api/board-assets/[...path]/route.js +2 -2
  49. package/.next/server/app/api/board-assets/[...path]/route_client-reference-manifest.js +1 -1
  50. package/.next/server/app/api/board-assets/route.js +2 -2
  51. package/.next/server/app/api/board-assets/route_client-reference-manifest.js +1 -1
  52. package/.next/server/app/api/boards/[id]/reconcile/route.js +1 -30
  53. package/.next/server/app/api/boards/[id]/reconcile/route.js.nft.json +1 -1
  54. package/.next/server/app/api/boards/[id]/reconcile/route_client-reference-manifest.js +1 -1
  55. package/.next/server/app/api/boards/[id]/route.js +1 -30
  56. package/.next/server/app/api/boards/[id]/route.js.nft.json +1 -1
  57. package/.next/server/app/api/boards/[id]/route_client-reference-manifest.js +1 -1
  58. package/.next/server/app/api/boards/reorder/route.js +2 -2
  59. package/.next/server/app/api/boards/reorder/route_client-reference-manifest.js +1 -1
  60. package/.next/server/app/api/boards/route.js +2 -2
  61. package/.next/server/app/api/boards/route_client-reference-manifest.js +1 -1
  62. package/.next/server/app/api/cwd/browse/route.js +1 -1
  63. package/.next/server/app/api/cwd/browse/route_client-reference-manifest.js +1 -1
  64. package/.next/server/app/api/cwd/validate/route.js +2 -2
  65. package/.next/server/app/api/cwd/validate/route_client-reference-manifest.js +1 -1
  66. package/.next/server/app/api/default-cwd/route.js +2 -2
  67. package/.next/server/app/api/default-cwd/route_client-reference-manifest.js +1 -1
  68. package/.next/server/app/api/file-index/route.js +2 -2
  69. package/.next/server/app/api/file-index/route_client-reference-manifest.js +1 -1
  70. package/.next/server/app/api/files/[...path]/route.js +2 -2
  71. package/.next/server/app/api/files/[...path]/route_client-reference-manifest.js +1 -1
  72. package/.next/server/app/api/git/diff/route.js +1 -1
  73. package/.next/server/app/api/git/diff/route_client-reference-manifest.js +1 -1
  74. package/.next/server/app/api/git/status/route.js +1 -1
  75. package/.next/server/app/api/git/status/route_client-reference-manifest.js +1 -1
  76. package/.next/server/app/api/home/route.js +1 -1
  77. package/.next/server/app/api/home/route_client-reference-manifest.js +1 -1
  78. package/.next/server/app/api/mcp/route.js +2 -2
  79. package/.next/server/app/api/mcp/route_client-reference-manifest.js +1 -1
  80. package/.next/server/app/api/models/route.js +30 -1
  81. package/.next/server/app/api/models/route.js.nft.json +1 -1
  82. package/.next/server/app/api/models/route_client-reference-manifest.js +1 -1
  83. package/.next/server/app/api/models-config/catalog/route.js +1 -1
  84. package/.next/server/app/api/models-config/catalog/route_client-reference-manifest.js +1 -1
  85. package/.next/server/app/api/models-config/discover/route.js +1 -1
  86. package/.next/server/app/api/models-config/discover/route_client-reference-manifest.js +1 -1
  87. package/.next/server/app/api/models-config/route.js +1 -1
  88. package/.next/server/app/api/models-config/route_client-reference-manifest.js +1 -1
  89. package/.next/server/app/api/models-config/test/route.js +1 -1
  90. package/.next/server/app/api/models-config/test/route_client-reference-manifest.js +1 -1
  91. package/.next/server/app/api/plugins/route.js +2 -2
  92. package/.next/server/app/api/plugins/route_client-reference-manifest.js +1 -1
  93. package/.next/server/app/api/project-trust/route.js +1 -1
  94. package/.next/server/app/api/project-trust/route.js.nft.json +1 -1
  95. package/.next/server/app/api/project-trust/route_client-reference-manifest.js +1 -1
  96. package/.next/server/app/api/quota/route.js +1 -1
  97. package/.next/server/app/api/quota/route_client-reference-manifest.js +1 -1
  98. package/.next/server/app/api/search/route.js +1 -1
  99. package/.next/server/app/api/search/route_client-reference-manifest.js +1 -1
  100. package/.next/server/app/api/sessions/[id]/auto-name/route.js +2 -2
  101. package/.next/server/app/api/sessions/[id]/auto-name/route.js.nft.json +1 -1
  102. package/.next/server/app/api/sessions/[id]/auto-name/route_client-reference-manifest.js +1 -1
  103. package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
  104. package/.next/server/app/api/sessions/[id]/context/route.js.nft.json +1 -1
  105. package/.next/server/app/api/sessions/[id]/context/route_client-reference-manifest.js +1 -1
  106. package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +2 -2
  107. package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route_client-reference-manifest.js +1 -1
  108. package/.next/server/app/api/sessions/[id]/export/route.js +3 -3
  109. package/.next/server/app/api/sessions/[id]/export/route_client-reference-manifest.js +1 -1
  110. package/.next/server/app/api/sessions/[id]/minimap/route.js +1 -1
  111. package/.next/server/app/api/sessions/[id]/minimap/route.js.nft.json +1 -1
  112. package/.next/server/app/api/sessions/[id]/minimap/route_client-reference-manifest.js +1 -1
  113. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  114. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  115. package/.next/server/app/api/sessions/[id]/route_client-reference-manifest.js +1 -1
  116. package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
  117. package/.next/server/app/api/sessions/[id]/state/route.js.nft.json +1 -1
  118. package/.next/server/app/api/sessions/[id]/state/route_client-reference-manifest.js +1 -1
  119. package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
  120. package/.next/server/app/api/sessions/[id]/todos/route.js.nft.json +1 -1
  121. package/.next/server/app/api/sessions/[id]/todos/route_client-reference-manifest.js +1 -1
  122. package/.next/server/app/api/sessions/route.js +1 -1
  123. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  124. package/.next/server/app/api/sessions/route_client-reference-manifest.js +1 -1
  125. package/.next/server/app/api/sessions/summary/route.js +2 -2
  126. package/.next/server/app/api/sessions/summary/route_client-reference-manifest.js +1 -1
  127. package/.next/server/app/api/skills/check/route.js +1 -1
  128. package/.next/server/app/api/skills/check/route_client-reference-manifest.js +1 -1
  129. package/.next/server/app/api/skills/install/route.js +2 -2
  130. package/.next/server/app/api/skills/install/route_client-reference-manifest.js +1 -1
  131. package/.next/server/app/api/skills/route.js +1 -1
  132. package/.next/server/app/api/skills/route_client-reference-manifest.js +1 -1
  133. package/.next/server/app/api/skills/search/route.js +1 -1
  134. package/.next/server/app/api/skills/search/route_client-reference-manifest.js +1 -1
  135. package/.next/server/app/api/skills/update/route.js +1 -1
  136. package/.next/server/app/api/skills/update/route_client-reference-manifest.js +1 -1
  137. package/.next/server/app/api/task-card-questions/[id]/answer/route.js +1 -33
  138. package/.next/server/app/api/task-card-questions/[id]/answer/route.js.nft.json +1 -1
  139. package/.next/server/app/api/task-card-questions/[id]/answer/route_client-reference-manifest.js +1 -1
  140. package/.next/server/app/api/task-card-questions/route.js +1 -33
  141. package/.next/server/app/api/task-card-questions/route.js.nft.json +1 -1
  142. package/.next/server/app/api/task-card-questions/route_client-reference-manifest.js +1 -1
  143. package/.next/server/app/api/task-cards/[id]/route.js +1 -30
  144. package/.next/server/app/api/task-cards/[id]/route.js.nft.json +1 -1
  145. package/.next/server/app/api/task-cards/[id]/route_client-reference-manifest.js +1 -1
  146. package/.next/server/app/api/task-cards/route.js +1 -30
  147. package/.next/server/app/api/task-cards/route.js.nft.json +1 -1
  148. package/.next/server/app/api/task-cards/route_client-reference-manifest.js +1 -1
  149. package/.next/server/app/api/task-scheduler/status/route.js +1 -1
  150. package/.next/server/app/api/task-scheduler/status/route.js.nft.json +1 -1
  151. package/.next/server/app/api/task-scheduler/status/route_client-reference-manifest.js +1 -1
  152. package/.next/server/app/api/tasks/[id]/assign-session/route.js +2 -2
  153. package/.next/server/app/api/tasks/[id]/assign-session/route_client-reference-manifest.js +1 -1
  154. package/.next/server/app/api/tasks/[id]/board/route.js +1 -30
  155. package/.next/server/app/api/tasks/[id]/board/route.js.nft.json +1 -1
  156. package/.next/server/app/api/tasks/[id]/board/route_client-reference-manifest.js +1 -1
  157. package/.next/server/app/api/tasks/[id]/route.js +1 -1
  158. package/.next/server/app/api/tasks/[id]/route.js.nft.json +1 -1
  159. package/.next/server/app/api/tasks/[id]/route_client-reference-manifest.js +1 -1
  160. package/.next/server/app/api/tasks/reorder/route.js +1 -1
  161. package/.next/server/app/api/tasks/reorder/route_client-reference-manifest.js +1 -1
  162. package/.next/server/app/api/tasks/route.js +2 -2
  163. package/.next/server/app/api/tasks/route_client-reference-manifest.js +1 -1
  164. package/.next/server/app/api/terminal/[id]/events/route.js +2 -2
  165. package/.next/server/app/api/terminal/[id]/events/route_client-reference-manifest.js +1 -1
  166. package/.next/server/app/api/terminal/[id]/route.js +2 -2
  167. package/.next/server/app/api/terminal/[id]/route_client-reference-manifest.js +1 -1
  168. package/.next/server/app/api/terminal/route.js +2 -2
  169. package/.next/server/app/api/terminal/route_client-reference-manifest.js +1 -1
  170. package/.next/server/app/api/worktrees/route.js +2 -2
  171. package/.next/server/app/api/worktrees/route_client-reference-manifest.js +1 -1
  172. package/.next/server/app/favicon.ico/route.js +1 -1
  173. package/.next/server/app/index.html +1 -1
  174. package/.next/server/app/index.rsc +5 -5
  175. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  176. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  177. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  178. package/.next/server/app/manifest.webmanifest/route.js +2 -2
  179. package/.next/server/app/manifest.webmanifest/route_client-reference-manifest.js +1 -1
  180. package/.next/server/app/page.js +3 -3
  181. package/.next/server/app/page_client-reference-manifest.js +1 -1
  182. package/.next/server/app-paths-manifest.json +13 -13
  183. package/.next/server/chunks/1937.js +4 -1
  184. package/.next/server/chunks/2430.js +4 -4
  185. package/.next/server/chunks/402.js +6 -5
  186. package/.next/server/chunks/4226.js +33 -0
  187. package/.next/server/chunks/542.js +1 -1
  188. package/.next/server/chunks/6429.js +1 -1
  189. package/.next/server/chunks/6525.js +2 -2
  190. package/.next/server/chunks/861.js +4 -4
  191. package/.next/server/middleware-build-manifest.js +1 -1
  192. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  193. package/.next/server/middleware.js +3 -3
  194. package/.next/server/next-font-manifest.js +1 -1
  195. package/.next/server/next-font-manifest.json +1 -1
  196. package/.next/server/pages/404.html +1 -1
  197. package/.next/server/pages/500.html +1 -1
  198. package/.next/server/server-reference-manifest.json +1 -1
  199. package/.next/static/chunks/{752.fca53104da35c3d4.js → 752.395b1b6b288c0c89.js} +1 -1
  200. package/.next/static/chunks/9272.b425ba57ea9bf5c9.js +1 -0
  201. package/.next/static/chunks/app/_not-found/page-8cea8d8de35f4da4.js +1 -0
  202. package/.next/static/chunks/app/{layout-58eb66e5aa7d2465.js → layout-916ab1fa1358450e.js} +1 -1
  203. package/.next/static/chunks/app/page-29d6d6b617029809.js +89 -0
  204. package/.next/static/chunks/main-app-dd2385a1a5a588df.js +1 -0
  205. package/.next/static/chunks/next/dist/client/components/builtin/global-error-be943dcb89867ab9.js +1 -0
  206. package/.next/static/chunks/{webpack-eb9d1c2dc43bec1f.js → webpack-1cac2d0ebd5afded.js} +1 -1
  207. package/.next/static/css/4a609fb185e7cff1.css +1 -0
  208. package/.next/trace +7 -8
  209. package/.next/trace-build +1 -1
  210. package/.next/types/app/api/agent/[id]/bash-output/route.ts +1 -1
  211. package/.next/types/app/api/agent/[id]/events/route.ts +1 -1
  212. package/.next/types/app/api/agent/[id]/route.ts +1 -1
  213. package/.next/types/app/api/agent/new/route.ts +1 -1
  214. package/.next/types/app/api/agent/running/route.ts +1 -1
  215. package/.next/types/app/api/app-update/route.ts +1 -1
  216. package/.next/types/app/api/auth/all-providers/route.ts +1 -1
  217. package/.next/types/app/api/auth/api-key/[provider]/route.ts +1 -1
  218. package/.next/types/app/api/auth/login/[provider]/route.ts +1 -1
  219. package/.next/types/app/api/auth/logout/[provider]/route.ts +1 -1
  220. package/.next/types/app/api/auth/providers/route.ts +1 -1
  221. package/.next/types/app/api/board-assets/[...path]/route.ts +1 -1
  222. package/.next/types/app/api/board-assets/route.ts +1 -1
  223. package/.next/types/app/api/boards/[id]/reconcile/route.ts +1 -1
  224. package/.next/types/app/api/boards/[id]/route.ts +1 -1
  225. package/.next/types/app/api/boards/reorder/route.ts +1 -1
  226. package/.next/types/app/api/boards/route.ts +1 -1
  227. package/.next/types/app/api/cwd/browse/route.ts +1 -1
  228. package/.next/types/app/api/cwd/validate/route.ts +1 -1
  229. package/.next/types/app/api/default-cwd/route.ts +1 -1
  230. package/.next/types/app/api/file-index/route.ts +1 -1
  231. package/.next/types/app/api/files/[...path]/route.ts +1 -1
  232. package/.next/types/app/api/git/diff/route.ts +1 -1
  233. package/.next/types/app/api/git/status/route.ts +1 -1
  234. package/.next/types/app/api/home/route.ts +1 -1
  235. package/.next/types/app/api/mcp/route.ts +1 -1
  236. package/.next/types/app/api/models/route.ts +1 -1
  237. package/.next/types/app/api/models-config/catalog/route.ts +1 -1
  238. package/.next/types/app/api/models-config/discover/route.ts +1 -1
  239. package/.next/types/app/api/models-config/route.ts +1 -1
  240. package/.next/types/app/api/models-config/test/route.ts +1 -1
  241. package/.next/types/app/api/plugins/route.ts +1 -1
  242. package/.next/types/app/api/project-trust/route.ts +1 -1
  243. package/.next/types/app/api/quota/route.ts +1 -1
  244. package/.next/types/app/api/search/route.ts +1 -1
  245. package/.next/types/app/api/sessions/[id]/auto-name/route.ts +1 -1
  246. package/.next/types/app/api/sessions/[id]/context/route.ts +1 -1
  247. package/.next/types/app/api/sessions/[id]/entries/[entryId]/thinking/route.ts +1 -1
  248. package/.next/types/app/api/sessions/[id]/export/route.ts +1 -1
  249. package/.next/types/app/api/sessions/[id]/minimap/route.ts +1 -1
  250. package/.next/types/app/api/sessions/[id]/route.ts +1 -1
  251. package/.next/types/app/api/sessions/[id]/state/route.ts +1 -1
  252. package/.next/types/app/api/sessions/[id]/todos/route.ts +1 -1
  253. package/.next/types/app/api/sessions/route.ts +1 -1
  254. package/.next/types/app/api/sessions/summary/route.ts +1 -1
  255. package/.next/types/app/api/skills/check/route.ts +1 -1
  256. package/.next/types/app/api/skills/install/route.ts +1 -1
  257. package/.next/types/app/api/skills/route.ts +1 -1
  258. package/.next/types/app/api/skills/search/route.ts +1 -1
  259. package/.next/types/app/api/skills/update/route.ts +1 -1
  260. package/.next/types/app/api/task-card-questions/[id]/answer/route.ts +1 -1
  261. package/.next/types/app/api/task-card-questions/route.ts +1 -1
  262. package/.next/types/app/api/task-cards/[id]/route.ts +1 -1
  263. package/.next/types/app/api/task-cards/route.ts +1 -1
  264. package/.next/types/app/api/task-scheduler/status/route.ts +1 -1
  265. package/.next/types/app/api/tasks/[id]/assign-session/route.ts +1 -1
  266. package/.next/types/app/api/tasks/[id]/board/route.ts +1 -1
  267. package/.next/types/app/api/tasks/[id]/route.ts +1 -1
  268. package/.next/types/app/api/tasks/reorder/route.ts +1 -1
  269. package/.next/types/app/api/tasks/route.ts +1 -1
  270. package/.next/types/app/api/terminal/[id]/events/route.ts +1 -1
  271. package/.next/types/app/api/terminal/[id]/route.ts +1 -1
  272. package/.next/types/app/api/terminal/route.ts +1 -1
  273. package/.next/types/app/api/worktrees/route.ts +1 -1
  274. package/.next/types/app/layout.ts +1 -1
  275. package/.next/types/app/page.ts +1 -1
  276. package/lib/board-reconcile.ts +85 -11
  277. package/lib/card-emoji.test.mjs +2 -2
  278. package/lib/rpc-manager.ts +15 -0
  279. package/lib/session-index-scanner.test.mjs +30 -4
  280. package/lib/session-index-scanner.ts +34 -15
  281. package/lib/session-reader.project.test.mjs +86 -0
  282. package/lib/session-reader.ts +75 -54
  283. package/lib/task-store.ts +1 -1
  284. package/lib/worktree.test.mjs +17 -1
  285. package/lib/worktree.ts +9 -0
  286. package/package.json +1 -1
  287. package/.next/server/chunks/5679.js +0 -30
  288. package/.next/static/chunks/7128.d1b8648ac645b7e8.js +0 -1
  289. package/.next/static/chunks/app/_not-found/page-5ebe9fbb3d6ecf60.js +0 -1
  290. package/.next/static/chunks/app/page-5c26c5ef6abe040a.js +0 -89
  291. package/.next/static/chunks/main-app-74d053c3937c473c.js +0 -1
  292. package/.next/static/chunks/next/dist/client/components/builtin/global-error-dc74675bfa68a62b.js +0 -1
  293. package/.next/static/css/eb9207b60f094f9b.css +0 -1
  294. /package/.next/static/{HY76ezjmCIjnvoJ-3If9w → 4mqb50aCQanTPXNyN1VZb}/_buildManifest.js +0 -0
  295. /package/.next/static/{HY76ezjmCIjnvoJ-3If9w → 4mqb50aCQanTPXNyN1VZb}/_ssgManifest.js +0 -0
@@ -15,6 +15,7 @@
15
15
  import { getBoard, listAllBoards } from "./board-store";
16
16
  import { listTaskSessionIds } from "./task-store";
17
17
  import { listCards, listLinks } from "./task-card-store";
18
+ import { getDb } from "./sqlite-db";
18
19
 
19
20
  // ---- yjs maps 类型 + mutateBoard(由 server.mjs 通过 globalThis 注入,避免 Next 打包 node:sqlite)----
20
21
  interface BoardMaps {
@@ -113,20 +114,20 @@ function overlapsSessionCards(nodes: DocNode[], x: number, y: number, width: num
113
114
  }
114
115
 
115
116
  /**
116
- * 任务执行会话卡落点:锚定任务卡右侧(y 与任务卡齐平),右侧被占则回退 findFreeSpot。
117
- * 任务卡节点已存在才锚定(补齐顺序保证先补任务卡再补会话卡)。
117
+ * 派生卡落点:锚定锚点节点右侧(y 与锚点齐平),右侧被占则回退 findFreeSpot。
118
+ * 锚点为任务卡(exec 执行会话卡)或源会话卡(fork 会话卡),补齐顺序保证先补锚点。
118
119
  */
119
- function findSpotNearTaskCard(
120
+ function findSpotNearAnchor(
120
121
  nodes: DocNode[],
121
- taskNode: DocNode | undefined,
122
+ anchorNode: DocNode | undefined,
122
123
  width: number,
123
124
  height: number,
124
125
  ): { x: number; y: number } {
125
- if (taskNode?.position) {
126
+ if (anchorNode?.position) {
126
127
  const STEP = 24;
127
- const taskW = taskNode.style?.width ?? TASK_CARD_W;
128
- const x = taskNode.position.x + taskW + STEP;
129
- const y = taskNode.position.y;
128
+ const anchorW = anchorNode.style?.width ?? TASK_CARD_W;
129
+ const x = anchorNode.position.x + anchorW + STEP;
130
+ const y = anchorNode.position.y;
130
131
  if (!overlapsSessionCards(nodes, x, y, width, height)) return { x, y };
131
132
  }
132
133
  return findFreeSpot(nodes, width, height);
@@ -153,6 +154,15 @@ export async function reconcileBoard(boardId: string): Promise<void> {
153
154
  const allSessionIds = new Set<string>();
154
155
  if (isTaskBoard) for (const sid of listTaskSessionIds(board.taskId!)) allSessionIds.add(sid);
155
156
  for (const c of cards) if (c.sessionId) allSessionIds.add(c.sessionId);
157
+ // fork 会话派生:fork(parent_id 非空)会话的源会话归属本任务 → 本任务看板补 fork 卡。
158
+ // fork 会话自身不继承 task_id,需经源会话归属定位;普通看板 board.taskId 为空天然不参与。
159
+ const forkBySid = new Map<string, string>(); // forkSid -> parentSid
160
+ if (isTaskBoard) {
161
+ for (const { sessionId, parentId } of listTaskForkedSessions(board.taskId!)) {
162
+ allSessionIds.add(sessionId);
163
+ forkBySid.set(sessionId, parentId);
164
+ }
165
+ }
156
166
 
157
167
  await mutateBoard(boardId, (maps) => {
158
168
  const nodesMap = maps.nodes;
@@ -226,8 +236,12 @@ export async function reconcileBoard(boardId: string): Promise<void> {
226
236
  // 执行会话卡无条件补:会话是业务派生的独立展示(真实会话的存在性),
227
237
  // 不依赖宿主任务卡节点在不在画布——任务卡是画布管理,执行会话卡是业务派生,
228
238
  // 两者解耦。任务卡不在时落点回退 findFreeSpot(findSpotNearTaskCard 内部处理)。
229
- const taskNode = card ? remaining.find((n) => n?.id === `task-${card.id}`) : undefined;
230
- const spot = findSpotNearTaskCard(remaining, taskNode, SESSION_CARD_W, SESSION_CARD_H);
239
+ const parentSid = forkBySid.get(sid);
240
+ // 锚点:fork 会话卡锚定源会话卡右侧;执行会话卡锚定宿主任务卡右侧;无锚点回退 4 列布局
241
+ const anchor = parentSid
242
+ ? remaining.find((n) => n?.id === `session-${parentSid}`)
243
+ : card ? remaining.find((n) => n?.id === `task-${card.id}`) : undefined;
244
+ const spot = findSpotNearAnchor(remaining, anchor, SESSION_CARD_W, SESSION_CARD_H);
231
245
  nodesMap.set(id, {
232
246
  id,
233
247
  type: "session-card",
@@ -301,7 +315,37 @@ export async function reconcileBoard(boardId: string): Promise<void> {
301
315
  });
302
316
  }
303
317
 
304
- // ---- 3) 依赖线:task_card_links建线(缺补多删)----
318
+ // ---- 3) fork 线:源会话卡 fork 会话卡(forkLink,同 exec 蓝虚线)----
319
+ const existingForkEdges = new Map<string, string>(); // "src->tgt" -> edgeId
320
+ for (const e of Array.from(edgesMap.values())) {
321
+ if (!e.data?.forkLink) continue;
322
+ if (e.source && e.target) existingForkEdges.set(`${e.source}->${e.target}`, e.id);
323
+ }
324
+ const wantForkEdges = new Set<string>();
325
+ for (const [sid, parentSid] of forkBySid) {
326
+ const srcId = `session-${parentSid}`;
327
+ const tgtId = `session-${sid}`;
328
+ // 两端节点在画布才连线(补卡循环已补 fork 卡;源卡归属任务恒存在/本轮补)
329
+ if (!sessionShapes.has(parentSid) || !sessionShapes.has(sid)) continue;
330
+ const key = `${srcId}->${tgtId}`;
331
+ wantForkEdges.add(key);
332
+ if (!existingForkEdges.has(key)) {
333
+ edgesMap.set(`fork-${parentSid}-${sid}`, {
334
+ id: `fork-${parentSid}-${sid}`,
335
+ source: srcId,
336
+ target: tgtId,
337
+ type: "default",
338
+ data: { forkLink: true, parentId: parentSid, sessionId: sid },
339
+ markerEnd: { type: "arrowclosed" },
340
+ style: { strokeWidth: 1.5, stroke: "#3184f8", strokeDasharray: "6 4" },
341
+ });
342
+ }
343
+ }
344
+ for (const [key, edgeId] of existingForkEdges) {
345
+ if (!wantForkEdges.has(key)) edgesMap.delete(edgeId);
346
+ }
347
+
348
+ // ---- 4) 依赖线:task_card_links → 建线(缺补多删)----
305
349
  const existingLinks = new Map(); // "<from>-><to>:<kind>" -> edgeId
306
350
  for (const e of Array.from(edgesMap.values())) {
307
351
  if (!e.data?.taskLink) continue;
@@ -339,6 +383,36 @@ export async function reconcileBoard(boardId: string): Promise<void> {
339
383
  });
340
384
  }
341
385
 
386
+ /** 源会话归属指定任务的 fork 会话(fork 关系 = session_meta.parent_id → 源会话 id)。
387
+ * 仅任务看板补 fork 卡:fork 卡是任务派生元素,经源会话的归属定位到任务看板。 */
388
+ function listTaskForkedSessions(taskId: string): Array<{ sessionId: string; parentId: string }> {
389
+ return getDb()
390
+ .prepare(
391
+ `SELECT f.session_id AS sessionId, f.parent_id AS parentId
392
+ FROM session_meta f
393
+ JOIN session_meta s ON s.session_id = f.parent_id
394
+ WHERE f.parent_id IS NOT NULL AND s.task_id = ?`,
395
+ )
396
+ .all(taskId) as Array<{ sessionId: string; parentId: string }>;
397
+ }
398
+
399
+ /**
400
+ * fork 后精准触发:源会话归属任务 → reconcile 该任务看板(fork 卡即时出现)。
401
+ * 源会话不归属任何任务(普通看板)→ 无操作。单板 reconcile,毫秒级;
402
+ * 失败不阻塞 fork,由 10s 定时兜底。
403
+ */
404
+ export async function reconcileForkBoard(sourceSessionId: string): Promise<void> {
405
+ const row = getDb()
406
+ .prepare("SELECT task_id AS taskId FROM session_meta WHERE session_id = ? AND task_id IS NOT NULL")
407
+ .get(sourceSessionId) as { taskId: string } | undefined;
408
+ if (!row?.taskId) return;
409
+ const boardRow = getDb()
410
+ .prepare("SELECT id FROM boards WHERE task_id = ?")
411
+ .get(row.taskId) as { id: string } | undefined;
412
+ if (!boardRow?.id) return;
413
+ await reconcileBoard(boardRow.id);
414
+ }
415
+
342
416
  /**
343
417
  * 从所有 RF 看板的 yjs 文档中删除指定会话的会话卡(含占位卡)并级联删边。
344
418
  *
@@ -7,13 +7,13 @@ const { DEFAULT_EMOJI, RANDOM_EMOJIS, randomEmoji, resolveEmoji } = await jiti.i
7
7
 
8
8
  test("类别默认:会话/任务/便笺固定初始值", () => {
9
9
  assert.equal(DEFAULT_EMOJI.session, "💬");
10
- assert.equal(DEFAULT_EMOJI.task, "");
10
+ assert.equal(DEFAULT_EMOJI.task, "🚩");
11
11
  assert.equal(DEFAULT_EMOJI.note, "📝");
12
12
  });
13
13
 
14
14
  test("resolveEmoji:用户设置优先于类别默认", () => {
15
15
  assert.equal(resolveEmoji("session", "🐱"), "🐱");
16
- assert.equal(resolveEmoji("task", undefined, "idle"), "");
16
+ assert.equal(resolveEmoji("task", undefined, "idle"), "🚩");
17
17
  assert.equal(resolveEmoji("note", "", null), "📝");
18
18
  });
19
19
 
@@ -16,6 +16,7 @@ import { cacheSessionPath, invalidateSessionListCache } from "./session-reader";
16
16
  import { projectIdentityKey } from "./project-identity";
17
17
  import { resolveProject } from "./worktree";
18
18
  import { ensureSessionMetaRow } from "./task-store";
19
+ import { reconcileForkBoard } from "./board-reconcile";
19
20
  import { getProjectTrustStatus, projectTrustReloadOptions } from "./project-trust";
20
21
  import { persistExplicitStartupPreferences } from "./startup-preferences";
21
22
  import type { SlashCommandInfo } from "@earendil-works/pi-coding-agent";
@@ -566,6 +567,16 @@ export class AgentSessionWrapper {
566
567
  const forkedPath = sourceManager.createBranchedSession(entry.parentId);
567
568
  if (!forkedPath) throw new Error("Failed to create forked session");
568
569
  newSessionFile = forkedPath;
570
+ // SDK 惰性落盘契约:fork 点之前无 assistant 消息时不写文件(flushed=false),
571
+ // 只返回路径字符串。若不强制落盘,后续 open 不存在文件会 newSession 生成新 id,
572
+ // meta 行指向不存在的文件——列表/看板卡片读不到,30s 扫描器删行 → “会话不存在”。
573
+ if (!existsSync(newSessionFile)) {
574
+ const content = [sourceManager.getHeader(), ...sourceManager.getEntries()]
575
+ .map((entry) => JSON.stringify(entry))
576
+ .join("\n") + "\n";
577
+ writeFileSync(newSessionFile, content, { encoding: "utf8", flag: "wx" });
578
+ (sourceManager as unknown as { flushed: boolean }).flushed = true;
579
+ }
569
580
  }
570
581
 
571
582
  const newSessionId = SessionManager.open(newSessionFile, sessionDir).getSessionId();
@@ -587,6 +598,10 @@ export class AgentSessionWrapper {
587
598
  projectKey: projectIdentityKey(project?.projectRoot ?? cwd ?? ""),
588
599
  parentId: sourceSessionId,
589
600
  });
601
+ // fork 卡即时入板:源会话归属任务 → 精准 reconcile 该任务看板。
602
+ // await 保证卡先落 yjs 再返回(前端响应到达时卡已存在,无并发写窗口);
603
+ // 失败不阻塞 fork,10s 定时兜底。
604
+ await reconcileForkBoard(sourceSessionId);
590
605
  } catch {
591
606
  // 建行失败不阻塞 fork:扫描器下一轮兜底补行。
592
607
  }
@@ -69,23 +69,49 @@ test("runSessionIndexScan:幂等(重复扫描不重复建行)", async () =
69
69
  }
70
70
  });
71
71
 
72
- test("runSessionIndexScan:mtime 变化只刷 modified,不重读", async () => {
72
+ test("runSessionIndexScan:mtime 变化刷 modified + 回填 first_message(文件是内容事实源)", async () => {
73
73
  freshDb();
74
74
  const { root, projDir } = makeRoot();
75
75
  try {
76
+ // 模拟 persistNewSessionFile 建行:文件尚无消息 → first_message NULL
76
77
  const file = join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl");
77
- writeFileSync(file, sessionFileLines("aaa", "/home/u/project/alpha"));
78
+ writeFileSync(file, [
79
+ `{"type":"session","version":3,"id":"aaa","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/home/u/project/alpha"}`,
80
+ ].join("\n") + "\n");
78
81
  await runSessionIndexScan(root);
82
+ assert.equal(getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='aaa'").get().first_message, null, "无消息 → NULL");
79
83
  const before = getDb().prepare("SELECT modified FROM session_meta WHERE session_id='aaa'").get().modified;
80
84
 
81
- // 追加内容 → mtime 变
85
+ // 用户发首条消息文件 mtime 变
82
86
  await new Promise((r) => setTimeout(r, 10));
83
- writeFileSync(file, sessionFileLines("aaa", "/home/u/project/alpha", "hello") + '{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":"hi"}}\n');
87
+ writeFileSync(file, sessionFileLines("aaa", "/home/u/project/alpha", "你好项目") + '{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":"hi"}}\n');
84
88
  const summary = await runSessionIndexScan(root);
85
89
  assert.equal(summary.inserted, 0);
86
90
  assert.equal(summary.updated, 1);
87
91
  const after = getDb().prepare("SELECT modified FROM session_meta WHERE session_id='aaa'").get().modified;
88
92
  assert.ok(after > before, "mtime 刷新");
93
+ const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='aaa'").get();
94
+ assert.equal(row.first_message, "你好项目", "mtime 分支回填 first_message");
95
+ } finally {
96
+ rmSync(root, { recursive: true, force: true });
97
+ }
98
+ });
99
+
100
+ test("runSessionIndexScan:first_message 历史欠账(NULL 且 mtime 未变)→ 补跑回填", async () => {
101
+ freshDb();
102
+ const { root, projDir } = makeRoot();
103
+ try {
104
+ const file = join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl");
105
+ writeFileSync(file, sessionFileLines("aaa", "/home/u/project/alpha", "历史首条"));
106
+ await runSessionIndexScan(root);
107
+ // 模拟老行欠账:文件有消息但 first_message 被清成 NULL(persist 建行冻结场景)
108
+ getDb().prepare("UPDATE session_meta SET first_message = NULL WHERE session_id='aaa'").run();
109
+
110
+ const summary = await runSessionIndexScan(root);
111
+ assert.equal(summary.inserted, 0);
112
+ assert.equal(summary.updated, 1, "NULL 行补跑计入 updated");
113
+ const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='aaa'").get();
114
+ assert.equal(row.first_message, "历史首条", "mtime 未变也回填");
89
115
  } finally {
90
116
  rmSync(root, { recursive: true, force: true });
91
117
  }
@@ -56,13 +56,14 @@ export async function runSessionIndexScan(
56
56
  const summary: SessionIndexScanSummary = { scanned: diskFiles.length, inserted: 0, updated: 0, deleted: 0 };
57
57
 
58
58
  const db = getDb();
59
- const selectAll = db.prepare("SELECT session_id, modified, path, parent_id FROM session_meta").all() as Array<{
59
+ const selectAll = db.prepare("SELECT session_id, modified, path, parent_id, first_message FROM session_meta").all() as Array<{
60
60
  session_id: string;
61
61
  modified: number | null;
62
62
  path: string | null;
63
63
  parent_id: string | null;
64
+ first_message: string | null;
64
65
  }>;
65
- const dbRows = new Map(selectAll.map((r) => [r.session_id, { modified: r.modified, path: r.path, parent_id: r.parent_id }]));
66
+ 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 }]));
66
67
 
67
68
  const seenOnDisk = new Set<string>();
68
69
 
@@ -88,6 +89,7 @@ export async function runSessionIndexScan(
88
89
  );
89
90
  const updateModifiedStmt = db.prepare("UPDATE session_meta SET modified = ? WHERE session_id = ?");
90
91
  const updateParentStmt = db.prepare("UPDATE session_meta SET parent_id = ? WHERE session_id = ?");
92
+ const updateFirstMessageStmt = db.prepare("UPDATE session_meta SET first_message = ? WHERE session_id = ?");
91
93
  const deleteStmt = db.prepare("DELETE FROM session_meta WHERE session_id = ?");
92
94
 
93
95
  // 读 header + resolve project + upsert(新文件或老行补全索引列共用)。
@@ -116,6 +118,26 @@ export async function runSessionIndexScan(
116
118
  );
117
119
  };
118
120
 
121
+ // 从磁盘读 head 并收敛派生列(parent_id 重挂 + first_message 回填)。
122
+ // first_message 是内容派生列:文件写入了首条消息,索引必须跟随——
123
+ // 否则 persist 建行的会话(first_message 恒 NULL)标题永远冻结。
124
+ // 返回是否更新了 first_message(供 NULL 欠账补跑计数)。
125
+ const reconcileFromHead = (file: { path: string; id: string }, dbRow: { parent_id: string | null; first_message: string | null }): boolean => {
126
+ const head = scanOneSessionHead(file.path);
127
+ if (!head) return false;
128
+ const parentId = head.parentSessionPath
129
+ ? (pathToId.get(sessionPathKey(head.parentSessionPath)) ?? null)
130
+ : null;
131
+ if (parentId !== dbRow.parent_id) {
132
+ updateParentStmt.run(parentId, file.id);
133
+ }
134
+ if (head.firstMessage && head.firstMessage !== dbRow.first_message) {
135
+ updateFirstMessageStmt.run(head.firstMessage, file.id);
136
+ return true;
137
+ }
138
+ return false;
139
+ };
140
+
119
141
  for (const file of diskFiles) {
120
142
  seenOnDisk.add(file.id);
121
143
  const dbRow = dbRows.get(file.id);
@@ -125,21 +147,18 @@ export async function runSessionIndexScan(
125
147
  await upsertFromDisk(file);
126
148
  summary.inserted += 1;
127
149
  } else if (dbRow.modified === null || dbRow.modified !== file.modified.getTime()) {
128
- // mtime 变化:刷新排序键;同时校验 parent 链——删除会话级联重挂/外部
129
- // 重挂会改 header.parentSession 但行内 parent_id 不会自己变。读 header
130
- // 首行(与 buildTaskSessionIndex 同成本)比对,不一致则更新:磁盘
131
- // header 是父链唯一权威,任何来源的重挂都在这里收敛。
132
- const head = scanOneSessionHead(file.path);
133
- if (head) {
134
- const parentId = head.parentSessionPath
135
- ? (pathToId.get(sessionPathKey(head.parentSessionPath)) ?? null)
136
- : null;
137
- if (parentId !== dbRow.parent_id) {
138
- updateParentStmt.run(parentId, file.id);
139
- }
140
- }
150
+ // mtime 变化:刷新排序键;同时收敛派生列——parent 链(删除会话级联
151
+ // 重挂/外部重挂会改 header.parentSession)与 first_message(内容写入
152
+ // 后标题必须跟随)。磁盘 header 是父链唯一权威,文件头是标题事实源。
153
+ reconcileFromHead(file, dbRow);
141
154
  updateModifiedStmt.run(file.modified.getTime(), file.id);
142
155
  summary.updated += 1;
156
+ } else if (!dbRow.first_message) {
157
+ // first_message 欠账补跑:persist 建行的老会话(first_message 恒 NULL)
158
+ // 在 mtime 已同步后仍需回填一次;文件已有首条消息则写入,否则保持 NULL。
159
+ if (reconcileFromHead(file, dbRow)) {
160
+ summary.updated += 1;
161
+ }
143
162
  }
144
163
  }
145
164
 
@@ -118,3 +118,89 @@ test("loadSessionSummariesByIds:按 id 点查摘要(title/lastReply/自定
118
118
  rmSync(root, { recursive: true, force: true });
119
119
  }
120
120
  });
121
+
122
+ test("loadTaskSessionsPageWithIndex:任务列表标题与聊天同源(session_meta,不依赖文件存在)", async () => {
123
+ freshDb();
124
+ markIndexReady();
125
+ // 文件都不存在(磁盘扫描快照外/已删):标题必须仍能从 session_meta 读出,
126
+ // 与聊天列表 loadProjectSessions 同源(旧实现 scanOneSessionFile 读文件 → 全跳过)。
127
+ seedSession({ id: "a", cwd: "/home/u/p", projectKey: "/home/u/p", taskId: "task-1", firstMessage: "任务首条", path: "/no/such/a.jsonl", modified: 300 });
128
+ seedSession({ id: "b", cwd: "/home/u/p", projectKey: "/home/u/p", taskId: "task-1", firstMessage: "另一条", path: "/no/such/b.jsonl", modified: 200 });
129
+ // 非任务会话 → 不返回
130
+ seedSession({ id: "c", cwd: "/home/u/p", projectKey: "/home/u/p", firstMessage: "游离", path: "/no/such/c.jsonl", modified: 100 });
131
+
132
+ const { loadTaskSessionsPageWithIndex } = await jiti.import("./session-reader.ts");
133
+ const index = {
134
+ metaById: new Map([
135
+ ["a", { path: "/no/such/a.jsonl", id: "a", modified: new Date(300) }],
136
+ ["b", { path: "/no/such/b.jsonl", id: "b", modified: new Date(200) }],
137
+ ]),
138
+ childrenOf: new Map(),
139
+ };
140
+ const res = await loadTaskSessionsPageWithIndex("task-1", index, 0, 5);
141
+ assert.equal(res.sessions.length, 2, "文件不存在但 meta 有行 → 仍返回(与聊天列表同源)");
142
+ assert.equal(res.sessions[0].id, "a");
143
+ assert.equal(res.sessions[0].firstMessage, "任务首条", "标题来自 session_meta.first_message");
144
+ assert.equal(res.sessions[1].firstMessage, "另一条");
145
+ assert.equal(res.sessions[0].name, undefined);
146
+ });
147
+
148
+ test("loadProjectSessions:first_message 空且文件有首条消息 → lazy 读文件补标题并写回 meta", async () => {
149
+ freshDb();
150
+ markIndexReady();
151
+ const root = mkdtempSync(join(tmpdir(), "pi-lazy-"));
152
+ const projDir = join(root, "--home-u-p--");
153
+ mkdirSync(projDir, { recursive: true });
154
+ try {
155
+ const cwd = "/home/u/p";
156
+ // 有消息但 meta 未同步(persist 建行冻结场景)→ lazy 应补
157
+ const file = join(projDir, "2026-01-01T00-00-00-000Z_a.jsonl");
158
+ writeFileSync(file, [
159
+ `{"type":"session","version":3,"id":"a","timestamp":"2026-01-01T00:00:00.000Z","cwd":"${cwd}"}`,
160
+ '{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"lazy 首条"}}',
161
+ ].join("\n") + "\n");
162
+ seedSession({ id: "a", cwd, projectKey: cwd, path: file, firstMessage: null });
163
+ // 真空会话:文件无消息 → 保持 no messages
164
+ const emptyFile = join(projDir, "2026-01-02T00-00-00-000Z_b.jsonl");
165
+ writeFileSync(emptyFile, [
166
+ `{"type":"session","version":3,"id":"b","timestamp":"2026-01-02T00:00:00.000Z","cwd":"${cwd}"}`,
167
+ ].join("\n") + "\n");
168
+ seedSession({ id: "b", cwd, projectKey: cwd, path: emptyFile, firstMessage: null });
169
+
170
+ const { loadProjectSessions } = await jiti.import("./session-reader.ts");
171
+ const sessions = await loadProjectSessions(cwd);
172
+ const byId = Object.fromEntries(sessions.map((s) => [s.id, s]));
173
+ assert.equal(byId.a.firstMessage, "lazy 首条", "lazy 读文件补标题");
174
+ assert.equal(byId.b.firstMessage, "(no messages)", "真空会话保持 no messages");
175
+ const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='a'").get();
176
+ assert.equal(row.first_message, "lazy 首条", "lazy 补读后写回 meta");
177
+ } finally {
178
+ rmSync(root, { recursive: true, force: true });
179
+ }
180
+ });
181
+
182
+ test("loadSessionDetailsFromMeta:任务列表同样 lazy 补标题并写回", async () => {
183
+ freshDb();
184
+ markIndexReady();
185
+ const root = mkdtempSync(join(tmpdir(), "pi-lazy-task-"));
186
+ const projDir = join(root, "--home-u-p--");
187
+ mkdirSync(projDir, { recursive: true });
188
+ try {
189
+ const cwd = "/home/u/p";
190
+ const file = join(projDir, "2026-01-01T00-00-00-000Z_a.jsonl");
191
+ writeFileSync(file, [
192
+ `{"type":"session","version":3,"id":"a","timestamp":"2026-01-01T00:00:00.000Z","cwd":"${cwd}"}`,
193
+ '{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"任务 lazy 首条"}}',
194
+ ].join("\n") + "\n");
195
+ seedSession({ id: "a", cwd, projectKey: cwd, path: file, firstMessage: null, taskId: "task-1" });
196
+
197
+ const { loadSessionDetailsFromMeta } = await jiti.import("./session-reader.ts");
198
+ const sessions = await loadSessionDetailsFromMeta(["a"]);
199
+ assert.equal(sessions.length, 1);
200
+ assert.equal(sessions[0].firstMessage, "任务 lazy 首条", "任务列表 lazy 补标题");
201
+ const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='a'").get();
202
+ assert.equal(row.first_message, "任务 lazy 首条", "写回 meta");
203
+ } finally {
204
+ rmSync(root, { recursive: true, force: true });
205
+ }
206
+ });
@@ -14,12 +14,81 @@ import { normalizeToolCalls } from "./normalize";
14
14
  import { projectIdentityKey } from "./project-identity";
15
15
  import { sessionPathKey } from "./session-path";
16
16
  import { resolveProject, type ProjectInfo } from "./worktree";
17
- import { scanSessionFiles, scanSessionFileMeta, scanOneSessionFile, sessionScanner } from "./session-scanner";
17
+ import { scanSessionFileMeta, scanOneSessionFile, scanOneSessionHead, sessionScanner } from "./session-scanner";
18
18
  import { ensureSessionIndexReady } from "./session-index-scanner";
19
19
  import type { TurnIndexItem } from "./api-types";
20
20
 
21
21
  export { getAgentDir };
22
22
 
23
+ type SessionMetaRow = Record<string, unknown>;
24
+
25
+ /** 单个 session_meta 行 → SessionInfo(聊天列表与任务列表共用的唯一映射)。 */
26
+ function mapSessionMetaRow(r: SessionMetaRow): SessionInfo {
27
+ return {
28
+ path: (r.path as string) ?? "",
29
+ id: r.session_id as string,
30
+ cwd: (r.cwd as string) ?? "",
31
+ name: (r.title as string | null) ?? undefined,
32
+ created: new Date((r.created as number) ?? 0).toISOString(),
33
+ modified: new Date((r.modified as number) ?? 0).toISOString(),
34
+ messageCount: 0,
35
+ firstMessage: (r.first_message as string | null) ?? "(no messages)",
36
+ parentSessionId: (r.parent_id as string | null) ?? undefined,
37
+ pinned: Boolean((r.pinned as number) ?? 0),
38
+ };
39
+ }
40
+
41
+ /** 按 id 集合查 session_meta 映射 SessionInfo(返回顺序与入参一致,无行 id 跳过)。
42
+ * 任务列表详情与聊天列表 loadProjectSessions 完全同源——标题/首条消息不再依赖
43
+ * 读文件(旧 readSessionDetails → scanOneSessionFile),统一由 session_meta 供给。 */
44
+ export async function loadSessionDetailsFromMeta(ids: string[]): Promise<SessionInfo[]> {
45
+ if (ids.length === 0) return [];
46
+ let rows: Array<Record<string, unknown>>;
47
+ try {
48
+ rows = getDb()
49
+ .prepare(
50
+ `SELECT session_id, path, cwd, title, first_message, parent_id, created, modified, pinned
51
+ FROM session_meta WHERE session_id IN (${ids.map(() => "?").join(",")})`,
52
+ )
53
+ .all(...ids) as Array<Record<string, unknown>>;
54
+ } catch {
55
+ return [];
56
+ }
57
+ const byId = new Map(rows.map((r) => [r.session_id as string, r]));
58
+ const sessions: SessionInfo[] = [];
59
+ for (const id of ids) {
60
+ const row = byId.get(id);
61
+ if (row) sessions.push(mapSessionMetaRow(row));
62
+ }
63
+ return fillFirstMessageFromFile(sessions);
64
+ }
65
+
66
+ /**
67
+ * lazy 回填:列表读取时对 first_message 为空的会话读文件头补真实首条消息
68
+ * (文件是内容事实源),并批量写回 session_meta(后台收敛,下次免读文件)。
69
+ * 真·空会话(文件无消息)保持 NULL → 显示 no messages。
70
+ * 列表(聊天/任务/全量)共用同一逻辑,保证各处标题一致。
71
+ */
72
+ function fillFirstMessageFromFile(sessions: SessionInfo[]): SessionInfo[] {
73
+ const pending = sessions.filter((s) => s.firstMessage === "(no messages)" && s.path);
74
+ if (pending.length === 0) return sessions;
75
+ const updateStmt = getDb().prepare("UPDATE session_meta SET first_message = ? WHERE session_id = ?");
76
+ return sessions.map((s) => {
77
+ if (s.firstMessage !== "(no messages)" || !s.path) return s;
78
+ let firstMessage = s.firstMessage;
79
+ try {
80
+ const head = scanOneSessionHead(s.path);
81
+ if (head?.firstMessage) {
82
+ firstMessage = head.firstMessage;
83
+ updateStmt.run(firstMessage, s.id);
84
+ }
85
+ } catch {
86
+ // 文件不可读(丢失/权限)→ 保持 no messages
87
+ }
88
+ return firstMessage === s.firstMessage ? s : { ...s, firstMessage };
89
+ });
90
+ }
91
+
23
92
  /**
24
93
  * 当前项目聊天区会话(列表重构 v2 主读取路径)。
25
94
  *
@@ -45,18 +114,7 @@ export async function loadProjectSessions(projectKey: string): Promise<SessionIn
45
114
  return [];
46
115
  }
47
116
 
48
- const sessions: SessionInfo[] = rows.map((r) => ({
49
- path: (r.path as string) ?? "",
50
- id: r.session_id as string,
51
- cwd: (r.cwd as string) ?? "",
52
- name: (r.title as string | null) ?? undefined,
53
- created: new Date((r.created as number) ?? 0).toISOString(),
54
- modified: new Date((r.modified as number) ?? 0).toISOString(),
55
- messageCount: 0,
56
- firstMessage: (r.first_message as string | null) ?? "(no messages)",
57
- parentSessionId: (r.parent_id as string | null) ?? undefined,
58
- pinned: Boolean((r.pinned as number) ?? 0),
59
- }));
117
+ const sessions = fillFirstMessageFromFile(rows.map(mapSessionMetaRow));
60
118
  return attachSessionProjectInfo(sessions);
61
119
  }
62
120
 
@@ -78,18 +136,7 @@ export async function loadAllSessionIndex(): Promise<SessionInfo[]> {
78
136
  return [];
79
137
  }
80
138
 
81
- const sessions: SessionInfo[] = rows.map((r) => ({
82
- path: (r.path as string) ?? "",
83
- id: r.session_id as string,
84
- cwd: (r.cwd as string) ?? "",
85
- name: (r.title as string | null) ?? undefined,
86
- created: new Date((r.created as number) ?? 0).toISOString(),
87
- modified: new Date((r.modified as number) ?? 0).toISOString(),
88
- messageCount: 0,
89
- firstMessage: (r.first_message as string | null) ?? "(no messages)",
90
- parentSessionId: (r.parent_id as string | null) ?? undefined,
91
- pinned: Boolean((r.pinned as number) ?? 0),
92
- }));
139
+ const sessions = fillFirstMessageFromFile(rows.map(mapSessionMetaRow));
93
140
  return attachSessionProjectInfo(sessions);
94
141
  }
95
142
 
@@ -166,33 +213,6 @@ async function loadAllSessions(): Promise<SessionInfo[]> {
166
213
  return attachSessionProjectInfo(sessions);
167
214
  }
168
215
 
169
- /** 阶段二:批量读详情(只读需要的子集)。返回顺序与入参一致。 */
170
- async function readSessionDetails(metas: Array<{ path: string; id: string; modified: Date }>): Promise<SessionInfo[]> {
171
- const pathToId = new Map<string, string>();
172
- for (const m of metas) pathToId.set(sessionPathKey(m.path), m.id);
173
- const sessions: SessionInfo[] = [];
174
- for (const meta of metas) {
175
- const scanned = scanOneSessionFile(meta.path);
176
- if (!scanned) continue;
177
- cacheSessionPath(scanned.id, scanned.path);
178
- sessions.push({
179
- path: scanned.path,
180
- id: scanned.id,
181
- cwd: scanned.cwd,
182
- name: scanned.name,
183
- created: scanned.created.toISOString(),
184
- modified: scanned.modified.toISOString(),
185
- messageCount: 0,
186
- firstMessage: scanned.firstMessage || "(no messages)",
187
- lastReply: scanned.lastReply || "",
188
- parentSessionId: scanned.parentSessionPath ? pathToId.get(sessionPathKey(scanned.parentSessionPath)) : undefined,
189
- transient: false,
190
- });
191
- }
192
- return sessions;
193
- }
194
-
195
-
196
216
  /** 任务会话详情按需分页(侧栏任务区)。
197
217
  *
198
218
  * 服务端分流:任务下的会话详情由 /api/tasks 直接下发,前端不再用
@@ -283,8 +303,9 @@ export async function loadTaskSessionsPageWithIndex(
283
303
  for (const rid of pageRootIds) {
284
304
  for (const id of collectSubtree(rid)) wantedIds.add(id);
285
305
  }
286
- const orderedMetas = [...wantedIds].map((id) => metaById.get(id)).filter((m): m is NonNullable<typeof m> => Boolean(m));
287
- const sessions = await attachSessionProjectInfo(await readSessionDetails(orderedMetas));
306
+ // 详情走 session_meta(与聊天列表 loadProjectSessions 同源)——标题/首条消息
307
+ // 由索引供给,不再读文件;不存在/未入索引的 id 自然跳过。
308
+ const sessions = await attachSessionProjectInfo(await loadSessionDetailsFromMeta([...wantedIds]));
288
309
 
289
310
  // sessionTotal:任务下全部根 + 子树节点数(删除确认文案)。
290
311
  const allIds = new Set<string>();
package/lib/task-store.ts CHANGED
@@ -33,7 +33,7 @@ const now = () => Date.now();
33
33
  */
34
34
  function invalidateSessionListCache(): void {
35
35
  try {
36
- // eslint-disable-next-line @typescript-eslint/no-var-requires
36
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
37
37
  require("./session-reader").invalidateSessionListCache();
38
38
  } catch {
39
39
  // 循环加载/不可用时忽略——下一次 force 刷新会重建缓存
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { execFile } from "node:child_process";
3
- import { mkdtemp, rm, writeFile } from "node:fs/promises";
3
+ import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
6
  import { promisify } from "node:util";
@@ -48,3 +48,19 @@ test("main and linked worktrees share one canonical project root", async (t) =>
48
48
  assert.ok(listedLinked);
49
49
  assert.equal(findCurrentWorktreePath(worktrees, `${linked}${path.sep}`), listedLinked.path);
50
50
  });
51
+
52
+ test("resolveCwdOrProjectRoot:已删除 worktree 目录回退主项目根;存在目录原样", async (t) => {
53
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pi-web-wt-rm-"));
54
+ t.after(() => rm(tempRoot, { recursive: true, force: true }));
55
+
56
+ const repo = path.join(tempRoot, "repo");
57
+ await execFileAsync("git", ["init", repo]);
58
+ // addWorktree 约定:worktree 落在 <repoRoot>-worktrees/<branch> 下
59
+ const removed = path.join(`${repo}-worktrees`, "feature-test");
60
+ await mkdir(removed, { recursive: true });
61
+ await rm(removed, { recursive: true, force: true }); // 模拟 worktree 目录已删除
62
+
63
+ const { resolveCwdOrProjectRoot } = await loadSubject();
64
+ assert.equal(await resolveCwdOrProjectRoot(removed), await realpath(repo), "已删 worktree 回退主根");
65
+ assert.equal(await resolveCwdOrProjectRoot(repo), repo, "存在目录原样");
66
+ });
package/lib/worktree.ts CHANGED
@@ -49,6 +49,15 @@ export function invalidateProjectCache(): void {
49
49
  globalThis.__piProjectCache?.clear();
50
50
  }
51
51
 
52
+ /** cwd 指向已删除的 worktree(目录不存在)时回退到主项目根;目录存在则原样返回。
53
+ * 会话 cwd 落盘即锁定,worktree 目录被删后,模型/信任/git/分支操作应继续按主
54
+ * checkout 可用(让用户能切回主分支),而不是 4xx 把页面卡死。 */
55
+ export async function resolveCwdOrProjectRoot(cwd: string): Promise<string> {
56
+ if (existsSync(cwd)) return cwd;
57
+ const project = await resolveProject(cwd);
58
+ return project.projectRoot || cwd;
59
+ }
60
+
52
61
  async function git(cwd: string, args: string[]): Promise<string> {
53
62
  const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
54
63
  timeout: 10_000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baique/pi-web-sky",
3
- "version": "0.1.42",
3
+ "version": "0.1.44",
4
4
  "description": "Web UI for the pi coding agent — Sky skin edition (fork of @agegr/pi-web / earendil-labs pi-web, MIT)",
5
5
  "homepage": "https://github.com/baique/pi-web-sky#readme",
6
6
  "repository": {