@rexvane/piweb 0.3.17 → 0.3.19

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 (261) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +6 -4
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/prerender-manifest.json +4 -4
  5. package/.next/react-loadable-manifest.json +23 -9
  6. package/.next/required-server-files.js +2 -1
  7. package/.next/required-server-files.json +2 -1
  8. package/.next/routes-manifest.json +12 -0
  9. package/.next/server/app/_global-error/page.js +2 -2
  10. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  11. package/.next/server/app/_global-error.html +1 -1
  12. package/.next/server/app/_global-error.rsc +1 -1
  13. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  16. package/.next/server/app/_not-found/page.js +1 -1
  17. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/server/app/_not-found.html +1 -1
  19. package/.next/server/app/_not-found.rsc +2 -2
  20. package/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  21. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +2 -2
  22. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/server/app/api/agent/[id]/events/route.js +3 -3
  24. package/.next/server/app/api/agent/[id]/events/route.js.nft.json +1 -1
  25. package/.next/server/app/api/agent/[id]/route.js +1 -1
  26. package/.next/server/app/api/agent/[id]/route.js.nft.json +1 -1
  27. package/.next/server/app/api/agent/[id]/tree/route.js +1 -1
  28. package/.next/server/app/api/agent/[id]/tree/route.js.nft.json +1 -1
  29. package/.next/server/app/api/agent/new/route.js +1 -1
  30. package/.next/server/app/api/agent/new/route.js.nft.json +1 -1
  31. package/.next/server/app/api/config/route.js +1 -1
  32. package/.next/server/app/api/config/route.js.nft.json +1 -1
  33. package/.next/server/app/api/files/route.js +1 -1
  34. package/.next/server/app/api/files/route.js.nft.json +1 -1
  35. package/.next/server/app/api/fs/route.js +1 -0
  36. package/.next/server/app/api/fs/route.js.nft.json +1 -0
  37. package/.next/server/app/api/fs/route_client-reference-manifest.js +1 -0
  38. package/.next/server/app/api/git/route.js +2 -2
  39. package/.next/server/app/api/git/route.js.nft.json +1 -1
  40. package/.next/server/app/api/growth/route.js +1 -1
  41. package/.next/server/app/api/growth/route.js.nft.json +1 -1
  42. package/.next/server/app/api/health/route.js +1 -1
  43. package/.next/server/app/api/models/route.js +1 -1
  44. package/.next/server/app/api/models/route.js.nft.json +1 -1
  45. package/.next/server/app/api/pi-settings/route.js +1 -1
  46. package/.next/server/app/api/pi-settings/route.js.nft.json +1 -1
  47. package/.next/server/app/api/plugins/route.js +1 -1
  48. package/.next/server/app/api/plugins/route.js.nft.json +1 -1
  49. package/.next/server/app/api/prompts/route.js +1 -1
  50. package/.next/server/app/api/prompts/route.js.nft.json +1 -1
  51. package/.next/server/app/api/remote/route.js +8 -0
  52. package/.next/server/app/api/remote/route.js.nft.json +1 -0
  53. package/.next/server/app/api/remote/route_client-reference-manifest.js +1 -0
  54. package/.next/server/app/api/security/route.js +1 -1
  55. package/.next/server/app/api/security/route.js.nft.json +1 -1
  56. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  57. package/.next/server/app/api/sessions/[id]/export/route.js.nft.json +1 -1
  58. package/.next/server/app/api/sessions/[id]/route.js +1 -1
  59. package/.next/server/app/api/sessions/[id]/route.js.nft.json +1 -1
  60. package/.next/server/app/api/sessions/import/route.js +2 -2
  61. package/.next/server/app/api/sessions/import/route.js.nft.json +1 -1
  62. package/.next/server/app/api/sessions/route.js +1 -1
  63. package/.next/server/app/api/sessions/route.js.nft.json +1 -1
  64. package/.next/server/app/api/skills/route.js +1 -1
  65. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  66. package/.next/server/app/api/update/route.js +2 -2
  67. package/.next/server/app/api/update/route.js.nft.json +1 -1
  68. package/.next/server/app/api/version/route.js +1 -1
  69. package/.next/server/app/api/version/route.js.nft.json +1 -1
  70. package/.next/server/app/api/web-auth/route.js +3 -1
  71. package/.next/server/app/api/workspaces/route.js +1 -1
  72. package/.next/server/app/api/workspaces/route.js.nft.json +1 -1
  73. package/.next/server/app/icon.svg/route.js +1 -1
  74. package/.next/server/app/index.html +1 -1
  75. package/.next/server/app/index.rsc +4 -4
  76. package/.next/server/app/index.segments/__PAGE__.segment.rsc +4 -4
  77. package/.next/server/app/index.segments/_full.segment.rsc +4 -4
  78. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  79. package/.next/server/app/login/page.js +1 -1
  80. package/.next/server/app/login/page_client-reference-manifest.js +1 -1
  81. package/.next/server/app/login.html +1 -1
  82. package/.next/server/app/login.rsc +3 -3
  83. package/.next/server/app/login.segments/_full.segment.rsc +3 -3
  84. package/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  85. package/.next/server/app/login.segments/login/__PAGE__.segment.rsc +3 -3
  86. package/.next/server/app/page.js +1 -1
  87. package/.next/server/app/page.js.nft.json +1 -1
  88. package/.next/server/app/page_client-reference-manifest.js +1 -1
  89. package/.next/server/app-paths-manifest.json +6 -4
  90. package/.next/server/chunks/229.js +1 -1
  91. package/.next/server/chunks/243.js +1 -0
  92. package/.next/server/chunks/393.js +1 -0
  93. package/.next/server/chunks/496.js +1 -1
  94. package/.next/server/chunks/538.js +1 -0
  95. package/.next/server/chunks/686.js +1 -4
  96. package/.next/server/chunks/802.js +2 -0
  97. package/.next/server/chunks/807.js +1 -0
  98. package/.next/server/chunks/920.js +4 -0
  99. package/.next/server/chunks/924.js +6 -0
  100. package/.next/server/instrumentation.js +1 -0
  101. package/.next/server/instrumentation.js.nft.json +1 -0
  102. package/.next/server/middleware-build-manifest.js +1 -1
  103. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  104. package/.next/server/middleware.js +4 -3
  105. package/.next/server/pages/404.html +1 -1
  106. package/.next/server/pages/500.html +1 -1
  107. package/.next/server/server-reference-manifest.json +1 -1
  108. package/.next/static/JDGSR-TIbk9qZWcUNYIX3/_buildManifest.js +1 -0
  109. package/.next/static/chunks/2716.7ad073038e94a802.js +1 -0
  110. package/.next/static/chunks/316-5ea443ee6b512aa9.js +1 -0
  111. package/.next/static/chunks/4673.8a5a9e6855350e34.js +1 -0
  112. package/.next/static/chunks/4686-e3825f3b6a384aa1.js +1 -0
  113. package/.next/static/chunks/4778.e4c9ed093a413bc4.js +1 -0
  114. package/.next/static/chunks/5173.91ddb803785009f8.js +1 -0
  115. package/.next/static/chunks/839.7403c6bdace4c56c.js +1 -0
  116. package/.next/static/chunks/8552.6bfe3e101250b20f.js +1 -0
  117. package/.next/static/chunks/app/_global-error/page-b179d9f7f6f2daf9.js +1 -0
  118. package/.next/static/chunks/app/api/agent/[id]/events/route-b179d9f7f6f2daf9.js +1 -0
  119. package/.next/static/chunks/app/api/agent/[id]/route-b179d9f7f6f2daf9.js +1 -0
  120. package/.next/static/chunks/app/api/agent/[id]/tree/route-b179d9f7f6f2daf9.js +1 -0
  121. package/.next/static/chunks/app/api/agent/new/route-b179d9f7f6f2daf9.js +1 -0
  122. package/.next/static/chunks/app/api/config/route-b179d9f7f6f2daf9.js +1 -0
  123. package/.next/static/chunks/app/api/files/route-b179d9f7f6f2daf9.js +1 -0
  124. package/.next/static/chunks/app/api/fs/route-b179d9f7f6f2daf9.js +1 -0
  125. package/.next/static/chunks/app/api/git/route-b179d9f7f6f2daf9.js +1 -0
  126. package/.next/static/chunks/app/api/growth/route-b179d9f7f6f2daf9.js +1 -0
  127. package/.next/static/chunks/app/api/health/route-b179d9f7f6f2daf9.js +1 -0
  128. package/.next/static/chunks/app/api/models/route-b179d9f7f6f2daf9.js +1 -0
  129. package/.next/static/chunks/app/api/pi-settings/route-b179d9f7f6f2daf9.js +1 -0
  130. package/.next/static/chunks/app/api/plugins/route-b179d9f7f6f2daf9.js +1 -0
  131. package/.next/static/chunks/app/api/prompts/route-b179d9f7f6f2daf9.js +1 -0
  132. package/.next/static/chunks/app/api/remote/route-b179d9f7f6f2daf9.js +1 -0
  133. package/.next/static/chunks/app/api/security/route-b179d9f7f6f2daf9.js +1 -0
  134. package/.next/static/chunks/app/api/sessions/[id]/export/route-b179d9f7f6f2daf9.js +1 -0
  135. package/.next/static/chunks/app/api/sessions/[id]/route-b179d9f7f6f2daf9.js +1 -0
  136. package/.next/static/chunks/app/api/sessions/import/route-b179d9f7f6f2daf9.js +1 -0
  137. package/.next/static/chunks/app/api/sessions/route-b179d9f7f6f2daf9.js +1 -0
  138. package/.next/static/chunks/app/api/skills/route-b179d9f7f6f2daf9.js +1 -0
  139. package/.next/static/chunks/app/api/update/route-b179d9f7f6f2daf9.js +1 -0
  140. package/.next/static/chunks/app/api/version/route-b179d9f7f6f2daf9.js +1 -0
  141. package/.next/static/chunks/app/api/web-auth/route-b179d9f7f6f2daf9.js +1 -0
  142. package/.next/static/chunks/app/api/workspaces/route-b179d9f7f6f2daf9.js +1 -0
  143. package/.next/static/chunks/app/login/page-5e276a1eeb8867df.js +1 -0
  144. package/.next/static/chunks/app/page-2e425ef83fba0782.js +1 -0
  145. package/.next/static/chunks/next/dist/client/components/builtin/app-error-b179d9f7f6f2daf9.js +1 -0
  146. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-b179d9f7f6f2daf9.js +1 -0
  147. package/.next/static/chunks/next/dist/client/components/builtin/not-found-b179d9f7f6f2daf9.js +1 -0
  148. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-b179d9f7f6f2daf9.js +1 -0
  149. package/.next/static/chunks/webpack-bd2b9202ecbcdcaa.js +1 -0
  150. package/.next/static/css/7391c42e1ae617f9.css +1 -0
  151. package/.next/types/app/api/fs/route.ts +353 -0
  152. package/.next/types/app/api/remote/route.ts +353 -0
  153. package/.next/types/routes.d.ts +3 -1
  154. package/.next/types/validator.ts +18 -0
  155. package/README.md +56 -10
  156. package/next.config.ts +3 -2
  157. package/package.json +3 -3
  158. package/scripts/launcher.mjs +194 -13
  159. package/scripts/release.mjs +0 -2
  160. package/src/app/api/agent/[id]/events/route.ts +11 -6
  161. package/src/app/api/fs/route.ts +119 -0
  162. package/src/app/api/growth/route.ts +11 -6
  163. package/src/app/api/models/route.ts +21 -9
  164. package/src/app/api/remote/route.ts +67 -0
  165. package/src/app/api/sessions/[id]/export/route.ts +11 -1
  166. package/src/app/api/sessions/route.ts +3 -1
  167. package/src/app/api/web-auth/route.ts +45 -49
  168. package/src/app/api/workspaces/route.ts +16 -0
  169. package/src/app/globals.css +11 -3
  170. package/src/app/login/page.tsx +8 -60
  171. package/src/components/AppShell.tsx +202 -155
  172. package/src/components/ChatInput.tsx +61 -6
  173. package/src/components/ChatWindow.tsx +274 -132
  174. package/src/components/DirectoryPicker.tsx +189 -0
  175. package/src/components/DrawerPanel.tsx +164 -0
  176. package/src/components/FileViewer.tsx +2 -2
  177. package/src/components/GrowthTree.tsx +6 -5
  178. package/src/components/MarkdownBlock.tsx +134 -0
  179. package/src/components/OutlineRail.tsx +22 -1
  180. package/src/components/ProjectPanel.tsx +197 -14
  181. package/src/components/RemoteSection.tsx +232 -0
  182. package/src/components/SessionImportSection.tsx +60 -38
  183. package/src/components/SettingsPanel.tsx +104 -57
  184. package/src/components/icons.tsx +23 -0
  185. package/src/hooks/useGrowth.ts +47 -25
  186. package/src/hooks/usePiWeb.ts +100 -14
  187. package/src/i18n.tsx +115 -28
  188. package/src/instrumentation-node.ts +35 -0
  189. package/src/instrumentation.ts +18 -0
  190. package/src/lib/agent-config.ts +134 -0
  191. package/src/lib/agent-manager.ts +243 -61
  192. package/src/lib/auth-throttle.ts +102 -0
  193. package/src/lib/auth.ts +35 -27
  194. package/src/lib/files-service.ts +61 -1
  195. package/src/lib/font.ts +84 -0
  196. package/src/lib/git-service.ts +5 -3
  197. package/src/lib/growth-service.ts +78 -30
  198. package/src/lib/growth-tracker.ts +91 -9
  199. package/src/lib/models-service.ts +36 -11
  200. package/src/lib/pi-settings.ts +10 -12
  201. package/src/lib/pi.ts +145 -18
  202. package/src/lib/plugins-service.ts +67 -35
  203. package/src/lib/remote/assets.json +4 -0
  204. package/src/lib/remote/mobile-proxy.ts +149 -0
  205. package/src/lib/remote/supervisor.ts +482 -0
  206. package/src/lib/security-service.ts +6 -11
  207. package/src/lib/session-import/claude.ts +45 -21
  208. package/src/lib/session-reader.ts +106 -3
  209. package/src/lib/skills-service.ts +34 -12
  210. package/src/lib/text-sanitize.ts +12 -0
  211. package/src/lib/theme.ts +3 -0
  212. package/src/lib/types.ts +16 -2
  213. package/src/lib/update-service.ts +0 -1
  214. package/src/lib/web-auth-shared.ts +5 -1
  215. package/src/lib/web-auth.ts +139 -22
  216. package/src/lib/web-launch-token.ts +55 -0
  217. package/src/lib/web-secret.ts +182 -0
  218. package/src/lib/workspace-store.ts +91 -7
  219. package/src/proxy.ts +46 -12
  220. package/.next/server/chunks/532.js +0 -1
  221. package/.next/static/chunks/2155.2f27fe7098987699.js +0 -1
  222. package/.next/static/chunks/2608.ec8ff1f5767c9056.js +0 -1
  223. package/.next/static/chunks/2716.93be8ad5c970c516.js +0 -1
  224. package/.next/static/chunks/4632.112d2e893d48d188.js +0 -1
  225. package/.next/static/chunks/4673.c1fb1580efc6ace7.js +0 -1
  226. package/.next/static/chunks/4686-f4a037d609cea3f2.js +0 -1
  227. package/.next/static/chunks/692-7992db0ae9cc3021.js +0 -1
  228. package/.next/static/chunks/app/_global-error/page-f77e9ede9808b18e.js +0 -1
  229. package/.next/static/chunks/app/api/agent/[id]/events/route-f77e9ede9808b18e.js +0 -1
  230. package/.next/static/chunks/app/api/agent/[id]/route-f77e9ede9808b18e.js +0 -1
  231. package/.next/static/chunks/app/api/agent/[id]/tree/route-f77e9ede9808b18e.js +0 -1
  232. package/.next/static/chunks/app/api/agent/new/route-f77e9ede9808b18e.js +0 -1
  233. package/.next/static/chunks/app/api/config/route-f77e9ede9808b18e.js +0 -1
  234. package/.next/static/chunks/app/api/files/route-f77e9ede9808b18e.js +0 -1
  235. package/.next/static/chunks/app/api/git/route-f77e9ede9808b18e.js +0 -1
  236. package/.next/static/chunks/app/api/growth/route-f77e9ede9808b18e.js +0 -1
  237. package/.next/static/chunks/app/api/health/route-f77e9ede9808b18e.js +0 -1
  238. package/.next/static/chunks/app/api/models/route-f77e9ede9808b18e.js +0 -1
  239. package/.next/static/chunks/app/api/pi-settings/route-f77e9ede9808b18e.js +0 -1
  240. package/.next/static/chunks/app/api/plugins/route-f77e9ede9808b18e.js +0 -1
  241. package/.next/static/chunks/app/api/prompts/route-f77e9ede9808b18e.js +0 -1
  242. package/.next/static/chunks/app/api/security/route-f77e9ede9808b18e.js +0 -1
  243. package/.next/static/chunks/app/api/sessions/[id]/export/route-f77e9ede9808b18e.js +0 -1
  244. package/.next/static/chunks/app/api/sessions/[id]/route-f77e9ede9808b18e.js +0 -1
  245. package/.next/static/chunks/app/api/sessions/import/route-f77e9ede9808b18e.js +0 -1
  246. package/.next/static/chunks/app/api/sessions/route-f77e9ede9808b18e.js +0 -1
  247. package/.next/static/chunks/app/api/skills/route-f77e9ede9808b18e.js +0 -1
  248. package/.next/static/chunks/app/api/update/route-f77e9ede9808b18e.js +0 -1
  249. package/.next/static/chunks/app/api/version/route-f77e9ede9808b18e.js +0 -1
  250. package/.next/static/chunks/app/api/web-auth/route-f77e9ede9808b18e.js +0 -1
  251. package/.next/static/chunks/app/api/workspaces/route-f77e9ede9808b18e.js +0 -1
  252. package/.next/static/chunks/app/login/page-52ef4841cbd199a8.js +0 -1
  253. package/.next/static/chunks/app/page-b51087831582ee37.js +0 -1
  254. package/.next/static/chunks/next/dist/client/components/builtin/app-error-f77e9ede9808b18e.js +0 -1
  255. package/.next/static/chunks/next/dist/client/components/builtin/forbidden-f77e9ede9808b18e.js +0 -1
  256. package/.next/static/chunks/next/dist/client/components/builtin/not-found-f77e9ede9808b18e.js +0 -1
  257. package/.next/static/chunks/next/dist/client/components/builtin/unauthorized-f77e9ede9808b18e.js +0 -1
  258. package/.next/static/chunks/webpack-c31263fd83d75ce6.js +0 -1
  259. package/.next/static/css/850c2fd130ffa2fc.css +0 -1
  260. package/.next/static/zsynuO_9Kx6nrXgkTUJ04/_buildManifest.js +0 -1
  261. /package/.next/static/{zsynuO_9Kx6nrXgkTUJ04 → JDGSR-TIbk9qZWcUNYIX3}/_ssgManifest.js +0 -0
@@ -0,0 +1,102 @@
1
+ /**
2
+ * 登录失败的全局指数退避。
3
+ *
4
+ * 目前无调用方:密码登录已移除,留作将来需要人机凭据时的现成实现。
5
+ *
6
+ * PiWeb 只服务一个操作者,且默认绑 127.0.0.1:路由处理器拿不到可靠的客户端地址
7
+ * (x-forwarded-for 可伪造,直连时根本没有)。与其信任一个按 IP 的键,不如让所有失败
8
+ * 共用一个计数器,短暂挡住全部密码尝试。被误伤的「另一个用户」只有操作者本人,
9
+ * 有界等待可以接受,而爆破被压到大约每分钟一次。
10
+ *
11
+ * 状态挂在 globalThis 上,这样 Next 的热重载不会把计数器重置掉。
12
+ */
13
+
14
+ export const AUTH_THROTTLE_BASE_DELAY_MS = 1_000;
15
+ export const AUTH_THROTTLE_MAX_DELAY_MS = 60_000;
16
+
17
+ /**
18
+ * 最后一次失败之后多久忘掉计数器。必须大于最大退避,否则「等过一次封禁」就
19
+ * 等于把退避重置回基础值,反而给攻击者一轮新的尝试额度。
20
+ */
21
+ export const AUTH_THROTTLE_RESET_AFTER_MS = 5 * 60_000;
22
+
23
+ export interface AuthThrottleState {
24
+ failures: number;
25
+ lastFailureAt: number;
26
+ blockedUntil: number;
27
+ }
28
+
29
+ const STATE_KEY = "piweb:auth-throttle";
30
+
31
+ function freshState(): AuthThrottleState {
32
+ return { failures: 0, lastFailureAt: 0, blockedUntil: 0 };
33
+ }
34
+
35
+ export function createAuthThrottleState(): AuthThrottleState {
36
+ return freshState();
37
+ }
38
+
39
+ function isState(value: unknown): value is AuthThrottleState {
40
+ return typeof value === "object"
41
+ && value !== null
42
+ && typeof (value as AuthThrottleState).failures === "number"
43
+ && typeof (value as AuthThrottleState).lastFailureAt === "number"
44
+ && typeof (value as AuthThrottleState).blockedUntil === "number";
45
+ }
46
+
47
+ function getGlobalState(): AuthThrottleState {
48
+ const store = globalThis as Record<PropertyKey, unknown>;
49
+ const key = Symbol.for(STATE_KEY);
50
+ const existing = store[key];
51
+ if (isState(existing)) return existing;
52
+ const created = freshState();
53
+ store[key] = created;
54
+ return created;
55
+ }
56
+
57
+ function expireIfStale(state: AuthThrottleState, now: number): void {
58
+ if (state.failures > 0 && now - state.lastFailureAt >= AUTH_THROTTLE_RESET_AFTER_MS) {
59
+ Object.assign(state, freshState());
60
+ }
61
+ }
62
+
63
+ export function backoffDelayMs(failures: number): number {
64
+ if (failures <= 0) return 0;
65
+ const exponent = Math.min(failures - 1, 31);
66
+ return Math.min(AUTH_THROTTLE_BASE_DELAY_MS * 2 ** exponent, AUTH_THROTTLE_MAX_DELAY_MS);
67
+ }
68
+
69
+ /** 还要等多少毫秒才允许下一次尝试;0 表示可以试 */
70
+ export function getAuthRetryAfterMs(now = Date.now(), state: AuthThrottleState = getGlobalState()): number {
71
+ expireIfStale(state, now);
72
+ return Math.max(0, state.blockedUntil - now);
73
+ }
74
+
75
+ /** 记一次失败,返回下一次尝试被强制的等待时间 */
76
+ export function recordAuthFailure(now = Date.now(), state: AuthThrottleState = getGlobalState()): number {
77
+ expireIfStale(state, now);
78
+ state.failures += 1;
79
+ state.lastFailureAt = now;
80
+ const delay = backoffDelayMs(state.failures);
81
+ state.blockedUntil = now + delay;
82
+ return delay;
83
+ }
84
+
85
+ /**
86
+ * 登录成功即清零(与上游 pi-web 的 recordAuthSuccess 一致)。
87
+ * 攻击者不知道密码就不可能触发这条路径,所以清零不会削弱防护,
88
+ * 而操作者输对一次之后不必继续背着之前的退避。
89
+ */
90
+ export function recordAuthSuccess(state: AuthThrottleState = getGlobalState()): void {
91
+ Object.assign(state, freshState());
92
+ }
93
+
94
+ /** 仅供测试:清掉挂在 globalThis 上的计数器 */
95
+ export function resetAuthThrottle(): void {
96
+ Object.assign(getGlobalState(), freshState());
97
+ }
98
+
99
+ /** Retry-After 头的整秒值;被封禁时至少 1 */
100
+ export function retryAfterSeconds(retryAfterMs: number): number {
101
+ return Math.max(1, Math.ceil(retryAfterMs / 1000));
102
+ }
package/src/lib/auth.ts CHANGED
@@ -1,28 +1,11 @@
1
- const BASIC_PREFIX = "Basic ";
2
-
3
- /** 恒定时间字符串比较(长度不同也走完全程,不早退) */
4
- export function equalText(left: string, right: string): boolean {
5
- const max = Math.max(left.length, right.length);
6
- let mismatch = left.length ^ right.length;
7
- for (let i = 0; i < max; i += 1) {
8
- mismatch |= (left.charCodeAt(i) || 0) ^ (right.charCodeAt(i) || 0);
9
- }
10
- return mismatch === 0;
11
- }
12
-
13
- export function validBasicAuthorization(header: string | null, password: string): boolean {
14
- if (!header?.startsWith(BASIC_PREFIX)) return false;
15
- try {
16
- const decoded = Buffer.from(header.slice(BASIC_PREFIX.length), "base64").toString("utf8");
17
- const separator = decoded.indexOf(":");
18
- if (separator < 0) return false;
19
- return equalText(decoded.slice(0, separator), "pi") && equalText(decoded.slice(separator + 1), password);
20
- } catch {
21
- return false;
22
- }
23
- }
1
+ import { isIP } from "node:net";
24
2
 
3
+ /**
4
+ * 同源校验。跨站一律拒绝(Sec-Fetch-Site 由浏览器给出,脚本改不了),
5
+ * 没有 Origin 的请求(curl、服务端调用)放行。
6
+ */
25
7
  export function isSafeOrigin(request: Request): boolean {
8
+ if (request.headers.get("sec-fetch-site") === "cross-site") return false;
26
9
  const origin = request.headers.get("origin");
27
10
  if (!origin) return true;
28
11
  try {
@@ -34,12 +17,36 @@ export function isSafeOrigin(request: Request): boolean {
34
17
  }
35
18
  }
36
19
 
37
- export function isSafeHost(request: Request, passwordConfigured: boolean): boolean {
38
- if (passwordConfigured) return true;
20
+ export function trustedHostnames(raw: string | undefined): string[] {
21
+ return (raw ?? "").split(",").map((value) => value.trim().toLowerCase()).filter(Boolean);
22
+ }
23
+
24
+ /**
25
+ * 白名单环境变量。PI_WEB_TRUSTED_HOSTS 是本项目的名字,PI_WEB_ALLOWED_HOSTS 是上游
26
+ * agegr/pi-web 的名字,两个都收,免得用户按上游文档配置却不生效。
27
+ */
28
+ export function configuredTrustedHosts(env: Record<string, string | undefined> = process.env): string[] {
29
+ return [...trustedHostnames(env.PI_WEB_TRUSTED_HOSTS), ...trustedHostnames(env.PI_WEB_ALLOWED_HOSTS)];
30
+ }
31
+
32
+ /**
33
+ * Host 校验:无论有没有开认证都要做。
34
+ *
35
+ * 只信任本机名(含 *.localhost)、IP 字面量、以及操作者显式列出的主机名。
36
+ * IP 字面量保留局域网直连能力,且不可能被 DNS rebinding 利用 —— 浏览器会把字面
37
+ * 地址原样写进 Host,攻击者域名对不上。反代转发的域名必须进白名单:
38
+ * DNS rebinding 靠的是攻击者域名解析到回环后仍带自己的 Host,它不可能等于白名单里的名字。
39
+ */
40
+ export function isSafeHost(request: Request, trustedHosts: readonly string[] = []): boolean {
39
41
  try {
40
42
  const host = request.headers.get("host") || new URL(request.url).host;
41
43
  const parsed = new URL(`http://${host}`);
42
- return !parsed.username && !parsed.password && parsed.host.toLowerCase() === host.toLowerCase() && isLoopbackHostname(parsed.hostname);
44
+ if (parsed.username || parsed.password || parsed.host.toLowerCase() !== host.toLowerCase()) return false;
45
+ const hostname = parsed.hostname.toLowerCase();
46
+ if (isLoopbackHostname(hostname)) return true;
47
+ // URL 给 IPv6 字面量带着方括号([fe80::1]),isIP 只认去掉括号的写法
48
+ if (isIP(hostname.replace(/^\[(.*)\]$/, "$1")) !== 0) return true;
49
+ return trustedHosts.includes(hostname);
43
50
  } catch {
44
51
  return false;
45
52
  }
@@ -47,5 +54,6 @@ export function isSafeHost(request: Request, passwordConfigured: boolean): boole
47
54
 
48
55
  export function isLoopbackHostname(hostname: string): boolean {
49
56
  const host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
50
- return host === "localhost" || host === "::1" || host === "0:0:0:0:0:0:0:1" || /^127(?:\.\d{1,3}){3}$/.test(host);
57
+ if (host === "localhost" || host.endsWith(".localhost")) return true;
58
+ return host === "::1" || host === "0:0:0:0:0:0:0:1" || /^127(?:\.\d{1,3}){3}$/.test(host);
51
59
  }
@@ -7,6 +7,7 @@ import fs from "node:fs/promises";
7
7
  import path from "node:path";
8
8
  import { BoundaryError, isPathInside, resolveWorkspacePath } from "./path-security";
9
9
  import { getAgentDir } from "./pi";
10
+ import { utf8Prefix } from "./text-sanitize";
10
11
 
11
12
  export interface FileEntry {
12
13
  name: string;
@@ -122,7 +123,7 @@ export async function readWorkspaceFile(cwdValue: unknown, relPath: unknown): Pr
122
123
  } finally {
123
124
  await handle.close();
124
125
  }
125
- const preview = buffer.subarray(0, Math.min(bytesRead, MAX_READ_BYTES));
126
+ const preview = utf8Prefix(buffer.subarray(0, bytesRead), MAX_READ_BYTES);
126
127
  // 只检查预览范围,绝不为识别二进制而加载整个大文件
127
128
  if (preview.includes(0)) {
128
129
  return { path: toRel(root, real), binary: true, truncated: false, content: "" };
@@ -185,9 +186,67 @@ export async function saveUpload(name: unknown, dataBase64: unknown): Promise<{
185
186
  throw error;
186
187
  }
187
188
  await handle.close();
189
+ scheduleUploadSweep();
188
190
  return { path: target, name: safeName, size: buffer.length };
189
191
  }
190
192
 
193
+ /** 上传超过这么多天、且没有任何会话记录提到它,才会被清理 */
194
+ export const UPLOAD_RETENTION_DAYS = 30;
195
+ const UPLOAD_SWEEP_INTERVAL_MS = 24 * 60 * 60 * 1000;
196
+ let lastUploadSweep = 0;
197
+
198
+ /** 上传成功后顺带在后台清理,每个进程每天最多一次 */
199
+ function scheduleUploadSweep(): void {
200
+ const now = Date.now();
201
+ if (now - lastUploadSweep < UPLOAD_SWEEP_INTERVAL_MS) return;
202
+ lastUploadSweep = now;
203
+ void sweepUploads(now).catch(() => undefined);
204
+ }
205
+
206
+ /**
207
+ * 清理 web-uploads:消息里引用的是上传文件的绝对路径,所以只要还有会话 JSONL 提到文件名就保留
208
+ * (会话被删、或归档过期删除后,它的附件才变成孤儿);没发出去的草稿留 30 天的余量。
209
+ * 会话目录读不全就整次放弃,宁可不删也不误删。返回删掉的文件名。
210
+ */
211
+ export async function sweepUploads(now = Date.now(), retentionDays = UPLOAD_RETENTION_DAYS): Promise<string[]> {
212
+ const agentDir = getAgentDir();
213
+ const dir = path.join(agentDir, "web-uploads");
214
+ const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
215
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
216
+ // 文件名 → 在 JSONL 里可能出现的写法(原样 / JSON 转义后)
217
+ const candidates = new Map<string, string[]>();
218
+ for (const entry of entries) {
219
+ if (!entry.isFile()) continue;
220
+ const stat = await fs.stat(path.join(dir, entry.name)).catch(() => null);
221
+ if (!stat || stat.mtimeMs > cutoff) continue;
222
+ candidates.set(entry.name, [entry.name, JSON.stringify(entry.name).slice(1, -1)]);
223
+ }
224
+ if (!candidates.size) return [];
225
+ const sessionsRoot = path.join(agentDir, "sessions");
226
+ let sessionFiles: string[];
227
+ try {
228
+ sessionFiles = (await fs.readdir(sessionsRoot, { recursive: true })).filter((name) => name.endsWith(".jsonl"));
229
+ } catch (error) {
230
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") return [];
231
+ sessionFiles = [];
232
+ }
233
+ for (const name of sessionFiles) {
234
+ let text: string;
235
+ try {
236
+ text = await fs.readFile(path.join(sessionsRoot, name), "utf8");
237
+ } catch {
238
+ return [];
239
+ }
240
+ for (const [file, needles] of candidates) if (needles.some((needle) => text.includes(needle))) candidates.delete(file);
241
+ if (!candidates.size) return [];
242
+ }
243
+ const removed: string[] = [];
244
+ for (const file of candidates.keys()) {
245
+ await fs.unlink(path.join(dir, file)).then(() => removed.push(file), () => undefined);
246
+ }
247
+ return removed;
248
+ }
249
+
191
250
  export function parseUploadLength(value: string | null): number {
192
251
  if (value === null || !/^\d+$/.test(value)) throw new BoundaryError("missing or invalid upload size");
193
252
  const size = Number(value);
@@ -220,6 +279,7 @@ export async function saveUploadStream(name: unknown, stream: ReadableStream<Uin
220
279
  if (size === 0) throw new BoundaryError("empty upload");
221
280
  if (expectedSize !== undefined && size !== expectedSize) throw new BoundaryError("incomplete upload: size does not match the declared length");
222
281
  complete = true;
282
+ scheduleUploadSweep();
223
283
  return { path: target, name: safeName, size };
224
284
  } finally {
225
285
  await reader.cancel().catch(() => undefined);
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 字体族偏好:界面字体与代码字体各自独立选择。
3
+ * 与字号(piweb.fontSize / piweb.chatFontSize,见 theme.ts applyFontSize)分开,
4
+ * 存 localStorage:piweb.fontUi / piweb.fontMono,
5
+ * 通过覆盖 CSS 变量 --font-ui / --font-mono 生效(变量默认值见 globals.css)。
6
+ *
7
+ * 值有两种写法:
8
+ * - 预设 id(如 "maple" / "system" / "jetbrains")
9
+ * - 自定义字体栈,前缀 "custom:"(如 "custom:Fira Code, monospace")
10
+ */
11
+
12
+ export type FontScope = "ui" | "mono";
13
+
14
+ export const DEFAULT_FONT_STACK: Record<FontScope, string> = {
15
+ ui: '"Maple Mono CN", ui-monospace, SFMono-Regular, Menlo, Consolas, "PingFang SC", "Microsoft YaHei", monospace',
16
+ mono: '"Maple Mono CN", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
17
+ };
18
+
19
+ export interface FontPreset {
20
+ id: string;
21
+ stack: string;
22
+ label: { zh: string; en: string };
23
+ }
24
+
25
+ export const FONT_PRESETS: Record<FontScope, FontPreset[]> = {
26
+ ui: [
27
+ { id: "maple", stack: DEFAULT_FONT_STACK.ui, label: { zh: "Maple Mono CN(默认)", en: "Maple Mono CN (default)" } },
28
+ { id: "system", stack: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif', label: { zh: "系统默认", en: "System default" } },
29
+ { id: "noto", stack: '"Noto Sans SC", ui-sans-serif, system-ui, "PingFang SC", "Microsoft YaHei", sans-serif', label: { zh: "思源黑体", en: "Noto Sans SC" } },
30
+ ],
31
+ mono: [
32
+ { id: "maple", stack: DEFAULT_FONT_STACK.mono, label: { zh: "Maple Mono CN(默认)", en: "Maple Mono CN (default)" } },
33
+ { id: "system", stack: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace", label: { zh: "系统等宽", en: "System monospace" } },
34
+ { id: "jetbrains", stack: '"JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', label: { zh: "JetBrains Mono", en: "JetBrains Mono" } },
35
+ { id: "fira", stack: '"Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace', label: { zh: "Fira Code", en: "Fira Code" } },
36
+ ],
37
+ };
38
+
39
+ export const CUSTOM_PREFIX = "custom:";
40
+ const KEY: Record<FontScope, string> = { ui: "piweb.fontUi", mono: "piweb.fontMono" };
41
+ const CSS_VAR: Record<FontScope, string> = { ui: "--font-ui", mono: "--font-mono" };
42
+
43
+ /** localStorage 里的原始偏好(预设 id 或 custom: 前缀的字体栈) */
44
+ export function loadFontChoice(scope: FontScope): string | null {
45
+ if (typeof window === "undefined") return null;
46
+ try {
47
+ return localStorage.getItem(KEY[scope]);
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ /** 把偏好解析成可直接写进 CSS 变量的字体栈;无法识别时回落默认 */
54
+ export function resolveFontStack(scope: FontScope, stored: string | null): string {
55
+ if (!stored) return DEFAULT_FONT_STACK[scope];
56
+ if (stored.startsWith(CUSTOM_PREFIX)) {
57
+ const custom = stored.slice(CUSTOM_PREFIX.length).trim();
58
+ return custom || DEFAULT_FONT_STACK[scope];
59
+ }
60
+ return FONT_PRESETS[scope].find((p) => p.id === stored)?.stack ?? DEFAULT_FONT_STACK[scope];
61
+ }
62
+
63
+ /** 应用并持久化;value 传 null 表示恢复默认(移除内联覆盖 + 清 localStorage) */
64
+ export function applyFontChoice(scope: FontScope, value: string | null): void {
65
+ if (typeof window === "undefined") return;
66
+ const root = document.documentElement;
67
+ try {
68
+ if (!value) {
69
+ root.style.removeProperty(CSS_VAR[scope]);
70
+ localStorage.removeItem(KEY[scope]);
71
+ return;
72
+ }
73
+ root.style.setProperty(CSS_VAR[scope], resolveFontStack(scope, value));
74
+ localStorage.setItem(KEY[scope], value);
75
+ } catch {
76
+ /* ignore */
77
+ }
78
+ }
79
+
80
+ /** 启动 / 系统主题变化 / 跨标签同步时重放两处字体偏好 */
81
+ export function applyAllFonts(): void {
82
+ applyFontChoice("ui", loadFontChoice("ui"));
83
+ applyFontChoice("mono", loadFontChoice("mono"));
84
+ }
@@ -7,6 +7,7 @@ import { execFile } from "node:child_process";
7
7
  import fs from "node:fs/promises";
8
8
  import path from "node:path";
9
9
  import { promisify } from "node:util";
10
+ import { resolveGitBin } from "./growth-service";
10
11
  import { BoundaryError, isPathInside, resolveWorkspacePath } from "./path-security";
11
12
 
12
13
  const exec = promisify(execFile);
@@ -61,8 +62,9 @@ const EMPTY: GitInfo = {
61
62
  /** 单次差异输出上限:超过则截断并标记(详情栏不是完整的 diff 工具) */
62
63
  const MAX_DIFF_BYTES = 400 * 1024;
63
64
 
64
- function runGit(cwd: string, args: string[], maxBuffer = 4 * 1024 * 1024): Promise<string> {
65
- return exec("git", args, { cwd, timeout: 15_000, maxBuffer, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" } }).then((r) => r.stdout);
65
+ /** 与生长记录共用解析出的 git(macOS 从 GUI / launchd 启动时 PATH 里常常没有 Homebrew 的 git) */
66
+ async function runGit(cwd: string, args: string[], maxBuffer = 4 * 1024 * 1024): Promise<string> {
67
+ return exec(await resolveGitBin(), args, { cwd, timeout: 15_000, maxBuffer, windowsHide: true, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0", GIT_TERMINAL_PROMPT: "0" } }).then((r) => r.stdout);
66
68
  }
67
69
 
68
70
  function kindOf(x: string, y: string): GitFileEntry["kind"] {
@@ -164,7 +166,7 @@ export async function gitInfo(cwdValue: unknown): Promise<GitInfo> {
164
166
  .split("\n")
165
167
  .filter(Boolean)
166
168
  .map((line) => {
167
- const [hash, subject, author, relative] = line.split("");
169
+ const [hash, subject, author, relative] = line.split("\x1f");
168
170
  return { hash, subject: subject ?? "", author: author ?? "", relative: relative ?? "" };
169
171
  });
170
172
  } catch {
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * growth-service:项目生长 = 每轮一个 git commit(工作区自带 git,无影子仓库、无旁路账本)。
3
3
  *
4
- * commit 直接落在工作区自己的 git 仓库里:非 git 目录在首次提交前自动 `git init`;
5
- * 工作区若只是别的仓库的子目录,则在自身根目录嵌套 init,保证记录范围恰好等于工作区。
4
+ * commit 直接落在工作区自己的 git 仓库里:工作区已是仓库根目录就直接用;
5
+ * 非 git 目录、或只是别的仓库的子目录,要用户同意后(allowInit)才在工作区根 `git init`
6
+ * (子目录则嵌套 init),保证记录范围恰好等于工作区;没同意就抛 needs-consent。
6
7
  * 每轮结束 = 往 PiWeb 独立暂存区 <gitdir>/piweb/index(GIT_INDEX_FILE 传入)add 全部改动,
7
8
  * write-tree + commit-tree,挂到专用引用 refs/piweb/rounds/<key>:不碰 HEAD、不碰用户暂存区、不占分支。
8
9
  * 排除规则 <gitdir>/piweb/exclude 只叠加、绝不改写用户的 info/exclude。
@@ -19,6 +20,7 @@ import { existsSync, realpathSync } from "node:fs";
19
20
  import fs from "node:fs/promises";
20
21
  import path from "node:path";
21
22
  import { GROWTH_EXCLUDE_DIRS } from "./growth-tree";
23
+ import { utf8Prefix } from "./text-sanitize";
22
24
  import type { GrowthChange, GrowthRound, GrowthRoundKind, GrowthRoundStatus } from "./types";
23
25
 
24
26
  /** 单文件快照上限:超过的不进快照(树里也就看不到它) */
@@ -42,10 +44,17 @@ export const EXCLUDE_DIRS = GROWTH_EXCLUDE_DIRS;
42
44
  const EXCLUDE_FILES = [".DS_Store", "Thumbs.db", "*.log", ".env", ".env.*", "!.env.example", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa*", "id_ed25519*"];
43
45
  const EXCLUDE_CONTENT = `# PiWeb growth snapshot exclusions (generated, do not edit)\n${[...EXCLUDE_DIRS.map((d) => `${d}/`), ...EXCLUDE_FILES].join("\n")}\n`;
44
46
 
47
+ /** 工作区还不是仓库根目录:not-git = 不在任何仓库里;nested = 是 parent 仓库的子目录 */
48
+ export interface GrowthInitTarget {
49
+ reason: "not-git" | "nested";
50
+ parent?: string;
51
+ }
52
+
45
53
  export class GrowthError extends Error {
46
54
  constructor(
47
55
  message: string,
48
- public code: "unavailable" | "too-large" | "not-found" | "failed" = "failed",
56
+ public code: "unavailable" | "too-large" | "not-found" | "failed" | "needs-consent" = "failed",
57
+ public target?: GrowthInitTarget,
49
58
  ) {
50
59
  super(message);
51
60
  this.name = "GrowthError";
@@ -120,9 +129,9 @@ type Env = Record<string, string | undefined>;
120
129
  /**
121
130
  * 解析 git 可执行文件:macOS GUI/launchd 启动的进程 PATH 常不含 Homebrew,
122
131
  * spawn("git") 会 ENOENT → 生长快照整体误判不可用。PATH 找不到时退回常见安装位置。
123
- * 只在非 Windows 生效;结果缓存到 registry(成功后不再变)。
132
+ * 只在非 Windows 生效;结果缓存到 registry(成功后不再变)。git-service 也用它。
124
133
  */
125
- async function resolveGitBin(): Promise<string> {
134
+ export async function resolveGitBin(): Promise<string> {
126
135
  if (registry.gitBin) return registry.gitBin;
127
136
  let bin = "git";
128
137
  if (process.platform !== "win32") {
@@ -207,10 +216,14 @@ async function gitOk(): Promise<boolean> {
207
216
  if (registry.gitFailedAt && Date.now() - registry.gitFailedAt < GIT_FAIL_RETRY_MS) return false;
208
217
  const check = runGit(["--version"]).then((r) => r.code === 0, () => false);
209
218
  registry.gitOk = check;
219
+ // 成功结果一直留着(项目栏每次打开都会问一次,不必每次起一个 git 进程);失败只短缓存
210
220
  void check.then((ok) => {
221
+ if (ok) {
222
+ registry.gitFailedAt = undefined;
223
+ return;
224
+ }
211
225
  registry.gitOk = undefined;
212
- if (ok) registry.gitFailedAt = undefined;
213
- else registry.gitFailedAt = Date.now();
226
+ registry.gitFailedAt = Date.now();
214
227
  });
215
228
  return check;
216
229
  }
@@ -285,26 +298,28 @@ async function refExistsFast(gitDir: string, ref: string): Promise<boolean> {
285
298
 
286
299
  /**
287
300
  * 解析工作区可用的 git-dir:工作区已是某仓库的工作树根就直接复用;
288
- * 否则(非 git 目录、或只是别的仓库的子目录)在工作区根 `git init`。
301
+ * 否则(非 git 目录、或只是别的仓库的子目录)只有 allowInit(用户同意过)才在工作区根 `git init`,
302
+ * 不然抛 needs-consent 并带上原因,由界面去问。
289
303
  * init 失败(目录不可写等)按终态处理,本会话停用生长跟踪。
290
304
  */
291
- async function ensureGitDir(cwd: string): Promise<string> {
292
- const usable = async (): Promise<string | null> => {
305
+ async function ensureGitDir(cwd: string, allowInit: boolean): Promise<string> {
306
+ const probe = async (): Promise<{ gitDir: string } | GrowthInitTarget> => {
293
307
  const r = await runGit(["rev-parse", "--absolute-git-dir", "--show-toplevel", "--is-bare-repository"], { cwd });
294
- if (r.code !== 0) return null;
308
+ if (r.code !== 0) return { reason: "not-git" };
295
309
  const [gitDir = "", top = "", bare = ""] = r.stdout.toString("utf8").split("\n").map((s) => s.trim());
296
- if (!gitDir || bare === "true" || !top) return null;
310
+ if (!gitDir || bare === "true" || !top) return { reason: "not-git" };
297
311
  // 快照范围必须恰好等于工作区:落在别的仓库的子目录里时,宁可嵌套 init 自己的仓库
298
- if (comparablePath(realpathSafe(top)) !== comparablePath(realpathSafe(cwd))) return null;
299
- return gitDir;
312
+ if (comparablePath(realpathSafe(top)) !== comparablePath(realpathSafe(cwd))) return { reason: "nested", parent: path.resolve(top) };
313
+ return { gitDir };
300
314
  };
301
- const existing = await usable();
302
- if (existing) return existing;
315
+ const existing = await probe();
316
+ if ("gitDir" in existing) return existing.gitDir;
317
+ if (!allowInit) throw new GrowthError("growth tracking needs consent to create a git repository here", "needs-consent", existing);
303
318
  const r = await runGit(["init", "-q"], { cwd });
304
319
  if (r.code !== 0) throw new GrowthError(`git init failed: ${r.stderr.trim().split("\n")[0] || `exit ${r.code}`}`, "unavailable");
305
- const created = await usable();
306
- if (!created) throw new GrowthError("workspace cannot be used as a git repository", "unavailable");
307
- return created;
320
+ const created = await probe();
321
+ if (!("gitDir" in created)) throw new GrowthError("workspace cannot be used as a git repository", "unavailable");
322
+ return created.gitDir;
308
323
  }
309
324
 
310
325
  /** 链头的 commit 与 tree;引用不存在返回 null(for-each-ref 对不存在的引用输出空、退出码 0) */
@@ -314,10 +329,10 @@ async function readHead(ws: WorkspaceState): Promise<{ commit: string; tree: str
314
329
  return HASH_RE.test(commit) && HASH_RE.test(tree) ? { commit, tree } : null;
315
330
  }
316
331
 
317
- async function createWorkspace(cwd: string): Promise<WorkspaceState> {
332
+ async function createWorkspace(cwd: string, allowInit: boolean): Promise<WorkspaceState> {
318
333
  if (!(await gitOk())) throw new GrowthError("git is not installed", "unavailable");
319
334
  const key = workspaceKey(cwd);
320
- const gitDir = await ensureGitDir(cwd);
335
+ const gitDir = await ensureGitDir(cwd, allowInit);
321
336
  const piwebDir = path.join(gitDir, "piweb");
322
337
  await fs.mkdir(piwebDir, { recursive: true });
323
338
  const ws: WorkspaceState = {
@@ -340,7 +355,8 @@ async function createWorkspace(cwd: string): Promise<WorkspaceState> {
340
355
  return ws;
341
356
  }
342
357
 
343
- async function getWorkspace(cwdValue: string): Promise<WorkspaceState> {
358
+ /** allowInit:工作区还不是仓库根目录时是否允许 `git init`(只有提交路径、且用户同意过才传 true;读取一律不建仓库) */
359
+ async function getWorkspace(cwdValue: string, allowInit = false): Promise<WorkspaceState> {
344
360
  const cwd = await fs.realpath(path.resolve(cwdValue)).catch(() => path.resolve(cwdValue));
345
361
  const key = workspaceKey(cwd);
346
362
  let pending = registry.workspaces.get(key);
@@ -355,7 +371,7 @@ async function getWorkspace(cwdValue: string): Promise<WorkspaceState> {
355
371
  }
356
372
  }
357
373
  if (!pending) {
358
- pending = createWorkspace(cwd);
374
+ pending = createWorkspace(cwd, allowInit);
359
375
  registry.workspaces.set(key, pending);
360
376
  pending.catch(() => registry.workspaces.delete(key));
361
377
  }
@@ -663,14 +679,39 @@ export interface RoundMeta {
663
679
  status?: GrowthRoundStatus;
664
680
  }
665
681
 
682
+ export interface CommitOptions {
683
+ /** 工作区还不是仓库根目录时允许 `git init`(用户同意过) */
684
+ allowInit?: boolean;
685
+ /** 拍完快照、提交之前再问一次:返回 true 就放弃这次提交(返回 null) */
686
+ abortIf?: () => boolean;
687
+ }
688
+
689
+ /** 每个工作区每提交这么多次跑一次 `git gc --auto`(与 git commit 自己的做法一样,没到阈值时什么也不做) */
690
+ const GC_EVERY_COMMITS = 50;
691
+ const commitsSinceGc = new Map<string, number>();
692
+
693
+ /** 不等、不设超时(大仓库打包可能要很久,中途杀掉白干);也不占工作区锁,并发由 git 自己的锁处理 */
694
+ async function gcInBackground(ws: WorkspaceState): Promise<void> {
695
+ const gitBin = await resolveGitBin();
696
+ const child = spawn(gitBin, ["--git-dir", ws.gitDir, "gc", "--auto", "--quiet"], {
697
+ cwd: ws.cwd,
698
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
699
+ stdio: "ignore",
700
+ windowsHide: true,
701
+ });
702
+ child.on("error", () => undefined);
703
+ child.unref();
704
+ }
705
+
666
706
  /**
667
707
  * 提交一个 commit 到 refs/piweb/rounds/<key>(做法等同 git add -A + git commit,但只用 PiWeb 独立暂存区)。
668
708
  * pi 的一轮无论有没有改动都提交(空 commit 也算一轮,轮号与对话一一对应);
669
709
  * 用户修改 / 基线只在工作区相对上一个 commit 有变化时提交,否则返回 null。
670
710
  */
671
- export async function commitRound(cwdValue: string, meta: RoundMeta): Promise<GrowthRound | null> {
672
- const ws = await getWorkspace(cwdValue);
711
+ export async function commitRound(cwdValue: string, meta: RoundMeta, options: CommitOptions = {}): Promise<GrowthRound | null> {
712
+ const ws = await getWorkspace(cwdValue, options.allowInit === true);
673
713
  return withLock(ws, async () => {
714
+ if (options.abortIf?.()) return null;
674
715
  if (ws.excludeRules !== EXCLUDE_CONTENT) {
675
716
  await fs.writeFile(ws.excludeFile, EXCLUDE_CONTENT, "utf8");
676
717
  ws.excludeRules = EXCLUDE_CONTENT;
@@ -678,6 +719,8 @@ export async function commitRound(cwdValue: string, meta: RoundMeta): Promise<Gr
678
719
  await refreshIndex(ws);
679
720
  const tree = (await shadow(ws, ["write-tree"])).stdout.toString("utf8").trim();
680
721
  assertHash(tree, "tree");
722
+ // 快照拍到一半时 pi 已经开始改文件:分不清谁改的,宁可不记(改动并入 pi 这一轮)
723
+ if (options.abortIf?.()) return null;
681
724
  const env = {
682
725
  GIT_AUTHOR_NAME: "PiWeb", GIT_AUTHOR_EMAIL: "piweb@localhost",
683
726
  GIT_COMMITTER_NAME: "PiWeb", GIT_COMMITTER_EMAIL: "piweb@localhost",
@@ -721,15 +764,20 @@ export async function commitRound(cwdValue: string, meta: RoundMeta): Promise<Gr
721
764
  }
722
765
  ws.lastCommit = commit;
723
766
  ws.lastTree = tree;
724
- // 不在用户仓库里主动 gc:对象由专用引用保活,仓库维护交还用户
767
+ // commit-tree 是底层命令,不会像 git commit 那样顺手 gc --auto:快照对象一直是松散文件、只增不减。
768
+ // 隔一段补一次,没到阈值 git 什么都不做。不带 PiWeb 暂存区:用户自己 index 里的对象照常算可达;
769
+ // PiWeb 暂存区此刻刚整体提交,里面的对象都已挂在专用引用上
770
+ const count = (commitsSinceGc.get(ws.key) ?? 0) + 1;
771
+ commitsSinceGc.set(ws.key, count % GC_EVERY_COMMITS);
772
+ if (count >= GC_EVERY_COMMITS) void gcInBackground(ws);
725
773
  return round;
726
774
  }
727
775
  });
728
776
  }
729
777
 
730
778
  /** 发 prompt 前调用:把两轮之间用户自己的修改单独记一笔(工作区还没有任何提交时记成基线),没改动不提交 */
731
- export function recordWorkspaceChanges(cwdValue: string, session: string): Promise<GrowthRound | null> {
732
- return commitRound(cwdValue, { kind: "user", session });
779
+ export function recordWorkspaceChanges(cwdValue: string, session: string, options: CommitOptions = {}): Promise<GrowthRound | null> {
780
+ return commitRound(cwdValue, { kind: "user", session }, options);
733
781
  }
734
782
 
735
783
  // ---------- 读取 ----------
@@ -835,7 +883,7 @@ export async function filePatch(cwdValue: string, from: unknown, to: unknown, re
835
883
  const r = await shadow(ws, ["diff", "-M", "--no-color", "--no-ext-diff", "-U1000000", assertHash(from, "from"), assertHash(to, "to"), "--", ...paths]);
836
884
  if (/^Binary files .* differ$/m.test(r.stdout.toString("utf8", 0, Math.min(r.stdout.length, 64 * 1024)))) return { patch: "", binary: true, truncated: false };
837
885
  if (r.stdout.length <= MAX_PATCH_BYTES) return { patch: r.stdout.toString("utf8"), binary: false, truncated: false };
838
- return { patch: r.stdout.subarray(0, MAX_PATCH_BYTES).toString("utf8"), binary: false, truncated: true };
886
+ return { patch: utf8Prefix(r.stdout, MAX_PATCH_BYTES).toString("utf8"), binary: false, truncated: true };
839
887
  }
840
888
 
841
889
  export interface GrowthContent {
@@ -855,5 +903,5 @@ export async function fileContent(cwdValue: string, tree: unknown, relPath: unkn
855
903
  const head = buf.subarray(0, Math.min(buf.length, 8000));
856
904
  if (head.includes(0)) return { content: "", binary: true, truncated: false, size: buf.length };
857
905
  if (buf.length <= MAX_CONTENT_BYTES) return { content: buf.toString("utf8"), binary: false, truncated: false, size: buf.length };
858
- return { content: buf.subarray(0, MAX_CONTENT_BYTES).toString("utf8"), binary: false, truncated: true, size: buf.length };
906
+ return { content: utf8Prefix(buf, MAX_CONTENT_BYTES).toString("utf8"), binary: false, truncated: true, size: buf.length };
859
907
  }