@rexvane/piweb 0.3.18 → 0.3.20

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 +8 -6
  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 +8 -6
  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/_Y3Lq0lI5RJ1tFSOnDi0L/_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-b1e9fa876d0ea671.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.d3fe337a9117c325.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-3614a8337843456b.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 +77 -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 +6 -0
  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 +265 -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 +117 -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 +155 -0
  205. package/src/lib/remote/supervisor.ts +492 -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/0cd968b9b62035b5.css +0 -1
  260. package/.next/static/en03hnPiuro0rnf8QkFEe/_buildManifest.js +0 -1
  261. /package/.next/static/{en03hnPiuro0rnf8QkFEe → _Y3Lq0lI5RJ1tFSOnDi0L}/_ssgManifest.js +0 -0
@@ -31,6 +31,18 @@ function toSummary(info: {
31
31
  };
32
32
  }
33
33
 
34
+ /** firstMessage 只当侧栏标题与搜索用:整段贴进来的长提问不该随每 3 秒一次的列表轮询反复下发 */
35
+ export const FIRST_MESSAGE_MAX_CHARS = 300;
36
+
37
+ function clipFirstMessage(text: string): string {
38
+ if (text.length <= FIRST_MESSAGE_MAX_CHARS) return text;
39
+ let end = FIRST_MESSAGE_MAX_CHARS;
40
+ const last = text.charCodeAt(end - 1);
41
+ // 别把代理对(emoji 等)切成半个
42
+ if (last >= 0xd800 && last <= 0xdbff) end -= 1;
43
+ return `${text.slice(0, end)}…`;
44
+ }
45
+
34
46
  /**
35
47
  * 单个 JSONL → 摘要,与 SDK `SessionManager.listAll()` 的口径一致:
36
48
  * 首行必须是 session 头;名字取最后一条 session_info;messageCount 计全部 message 条目;
@@ -82,7 +94,7 @@ async function summarize(filePath: string, stat: { mtime: Date }): Promise<Sessi
82
94
  created: new Date(String(header.timestamp)),
83
95
  modified,
84
96
  messageCount,
85
- firstMessage: firstMessage || "(no messages)",
97
+ firstMessage: clipFirstMessage(firstMessage) || "(no messages)",
86
98
  });
87
99
  } catch {
88
100
  return null;
@@ -90,10 +102,92 @@ async function summarize(filePath: string, stat: { mtime: Date }): Promise<Sessi
90
102
  }
91
103
 
92
104
  /** 按文件缓存的摘要:mtime 与大小都没变就不重读(侧栏每 3 秒轮询一次,全盘重读几 MB JSONL 太浪费) */
93
- const summaryCache = new Map<string, { mtimeMs: number; size: number; summary: SessionSummary | null }>();
105
+ const summaryCache = new Map<string, CachedSummary>();
94
106
  /** 并发列表请求共用一次扫描 */
95
107
  let scanning: Promise<SessionSummary[]> | null = null;
96
108
 
109
+ interface CachedSummary {
110
+ mtimeMs: number;
111
+ size: number;
112
+ summary: SessionSummary | null;
113
+ }
114
+
115
+ /**
116
+ * 摘要缓存落盘:进程内的 summaryCache 一重启就没了,而冷启动要把 ~/.pi/agent/sessions 下
117
+ * 的 JSONL 全量解析一遍(本机 36 个会话 / 17.5MB ≈ 136ms,会话越多越贵)。落盘后重启只需
118
+ * 按 mtime + 大小校验,命中就沿用摘要——侧栏首次渲染从"全量解析"变成"读一个小 JSON"。
119
+ * 索引只是加速:读不到、版本不符、写不进去都退化成全量扫描,绝不影响正确性。
120
+ */
121
+ /** 2:firstMessage 开始截断,旧索引里的整段原文要重新摘要 */
122
+ const INDEX_VERSION = 2;
123
+ const INDEX_FILENAME = "piweb-session-index.json";
124
+ const INDEX_MAX_ENTRIES = 5000;
125
+ const INDEX_SAVE_DELAY_MS = 1500;
126
+ let indexLoadedFrom: string | null = null;
127
+ let indexDirty = false;
128
+ let indexTimer: ReturnType<typeof setTimeout> | null = null;
129
+
130
+ function indexPath(): string {
131
+ return path.join(getAgentDir(), INDEX_FILENAME);
132
+ }
133
+
134
+ type IndexEntry = [mtimeMs: number, size: number, summary: SessionSummary | null];
135
+
136
+ async function loadIndex(): Promise<void> {
137
+ const target = indexPath();
138
+ if (indexLoadedFrom === target) return;
139
+ indexLoadedFrom = target;
140
+ // 会话库换了目录(PI_CODING_AGENT_DIR)就不能再沿用上一个目录的摘要
141
+ summaryCache.clear();
142
+ try {
143
+ const raw = JSON.parse(await fs.readFile(target, "utf8")) as { version?: unknown; entries?: Record<string, unknown> };
144
+ if (raw?.version !== INDEX_VERSION || !raw.entries || typeof raw.entries !== "object" || Array.isArray(raw.entries)) return;
145
+ for (const [file, entry] of Object.entries(raw.entries)) {
146
+ if (!Array.isArray(entry) || entry.length !== 3) continue;
147
+ const [mtimeMs, size, summary] = entry as IndexEntry;
148
+ if (typeof mtimeMs !== "number" || typeof size !== "number") continue;
149
+ summaryCache.set(file, { mtimeMs, size, summary: summary && typeof summary === "object" ? summary : null });
150
+ }
151
+ } catch {
152
+ /* 首次运行没有索引,或索引损坏:全量扫描即可 */
153
+ }
154
+ }
155
+
156
+ function scheduleIndexSave(): void {
157
+ indexDirty = true;
158
+ if (indexTimer) return;
159
+ indexTimer = setTimeout(() => {
160
+ indexTimer = null;
161
+ void saveIndex();
162
+ }, INDEX_SAVE_DELAY_MS);
163
+ indexTimer.unref?.();
164
+ }
165
+
166
+ async function saveIndex(): Promise<void> {
167
+ if (!indexDirty) return;
168
+ indexDirty = false;
169
+ // 写回缓存所属的那个目录:索引路径不能在落盘时重新解析(会话库换目录时会把 A 的摘要写进 B)
170
+ const target = indexLoadedFrom;
171
+ if (!target) return;
172
+ const entries: Record<string, IndexEntry> = {};
173
+ // 只留最近改动的若干条,避免索引本身无限长
174
+ for (const [file, cached] of [...summaryCache.entries()].sort((a, b) => b[1].mtimeMs - a[1].mtimeMs).slice(0, INDEX_MAX_ENTRIES)) {
175
+ entries[file] = [cached.mtimeMs, cached.size, cached.summary];
176
+ }
177
+ try {
178
+ await fs.mkdir(path.dirname(target), { recursive: true });
179
+ const temporary = `${target}.${process.pid}.tmp`;
180
+ try {
181
+ await fs.writeFile(temporary, JSON.stringify({ version: INDEX_VERSION, entries }), "utf8");
182
+ await fs.rename(temporary, target);
183
+ } finally {
184
+ await fs.rm(temporary, { force: true }).catch(() => undefined);
185
+ }
186
+ } catch {
187
+ /* 索引写不进去只影响速度,下一次列表请求照常扫描 */
188
+ }
189
+ }
190
+
97
191
  async function scanSessions(): Promise<SessionSummary[]> {
98
192
  const root = path.join(getAgentDir(), "sessions");
99
193
  const files: string[] = [];
@@ -113,6 +207,7 @@ async function scanSessions(): Promise<SessionSummary[]> {
113
207
  const seen = new Set<string>();
114
208
  const out: SessionSummary[] = [];
115
209
  const BATCH = 16;
210
+ let changed = false;
116
211
  for (let i = 0; i < files.length; i += BATCH) {
117
212
  await Promise.all(files.slice(i, i + BATCH).map(async (file) => {
118
213
  seen.add(file);
@@ -125,10 +220,17 @@ async function scanSessions(): Promise<SessionSummary[]> {
125
220
  }
126
221
  const summary = await summarize(file, stat);
127
222
  summaryCache.set(file, { mtimeMs: stat.mtimeMs, size: stat.size, summary });
223
+ changed = true;
128
224
  if (summary) out.push(summary);
129
225
  }));
130
226
  }
131
- for (const key of summaryCache.keys()) if (!seen.has(key)) summaryCache.delete(key);
227
+ for (const key of summaryCache.keys()) {
228
+ if (!seen.has(key)) {
229
+ summaryCache.delete(key);
230
+ changed = true;
231
+ }
232
+ }
233
+ if (changed) scheduleIndexSave();
132
234
  return out;
133
235
  }
134
236
 
@@ -140,6 +242,7 @@ export async function listSessions(query?: string): Promise<SessionSummary[]> {
140
242
  if (listCache && Date.now() - listCache.at < 1200) {
141
243
  list = listCache.list;
142
244
  } else {
245
+ await loadIndex();
143
246
  scanning ??= scanSessions().finally(() => { scanning = null; });
144
247
  list = await scanning;
145
248
  listCache = { at: Date.now(), list };
@@ -133,25 +133,47 @@ export async function readSkillFile(filePath: string, cwd?: string): Promise<str
133
133
  return fs.readFile(authorized, "utf8");
134
134
  }
135
135
 
136
- /** 删除技能(SKILL.md 所在目录)。包技能(node_modules 内)随包安装,删除会被还原 → 拒绝并用禁用管理。 */
136
+ /** 技能根与用户目录本身:哪怕 SKILL.md 直接放在里面,也只能删文件,绝不能删目录 */
137
+ function protectedDirs(context: ScopeContext): Set<string> {
138
+ const dirs = [
139
+ context.agentDir,
140
+ `${context.agentDir}/skills`,
141
+ `${context.agentDir}/web-skills`,
142
+ context.home,
143
+ `${context.home}/.pi`,
144
+ `${context.home}/.agents`,
145
+ `${context.home}/.agents/skills`,
146
+ ];
147
+ if (context.cwd) dirs.push(context.cwd, `${context.cwd}/.pi`, `${context.cwd}/.pi/skills`, `${context.cwd}/.agents`, `${context.cwd}/.agents/skills`);
148
+ return new Set(dirs);
149
+ }
150
+
151
+ /**
152
+ * 删除技能。包技能(node_modules 内)随包安装,删除会被还原 → 拒绝并用禁用管理。
153
+ *
154
+ * 只有「<名字>/SKILL.md」这种技能才连目录一起删。SDK 也认技能根下直接放的平铺 `foo.md`,
155
+ * 它的 dirname 就是技能根本身(~/.pi/agent/skills、项目 .pi/skills…),按目录删会把整个技能根
156
+ * 连同其它技能一起删掉 —— 所以平铺技能、以及目录不归它独占的情况,一律只删这一个文件。
157
+ */
137
158
  export async function deleteSkill(filePath: string, cwd?: string): Promise<{ removed: string }> {
138
159
  const skills = await listSkills(cwd);
139
160
  const authorized = await resolveDiscoveredPath(filePath, skills.map((skill) => skill.filePath));
140
161
  const context = await scopeContext(cwd);
141
- if ((await classifyScope(authorized, context)) === "package") {
162
+ const scope = await classifyScope(authorized, context);
163
+ if (scope === "package") {
142
164
  throw new Error("package-managed skill: disable it instead (it is reinstalled with the package)");
143
165
  }
144
166
  const skillDir = path.dirname(authorized);
145
- // 只删技能目录本身,不能误删父目录(全局技能目录 ~/.pi/agent/skills、项目 .agents/skills 等)
146
- const normalizedSkillDir = await toComparablePath(skillDir);
147
- const knownRoots = [`${context.agentDir}/skills`, `${context.agentDir}/web-skills`];
148
- if (!knownRoots.includes(normalizedSkillDir)) {
149
- // 非已知根:项目内技能目录,至少确认 SKILL.md 直接位于其下且目录名非空
150
- if (path.basename(skillDir) === "") throw new Error("refusing to delete workspace root");
151
- }
152
- await fs.rm(skillDir, { recursive: true, force: true });
167
+ const comparableDir = await toComparablePath(skillDir);
168
+ const comparableFile = await toComparablePath(authorized);
169
+ const others = await Promise.all(skills.map((skill) => toComparablePath(skill.filePath)));
170
+ const ownsDir = path.basename(authorized) === "SKILL.md"
171
+ && path.dirname(skillDir) !== skillDir
172
+ && !protectedDirs(context).has(comparableDir)
173
+ && !others.some((other) => other !== comparableFile && within(other, comparableDir));
174
+ const removed = ownsDir ? skillDir : authorized;
175
+ await fs.rm(removed, { recursive: ownsDir, force: true });
153
176
  await reloadAllLoaders();
154
- const scope = await classifyScope(authorized, context);
155
177
  await reloadSessionsForCwd(scope === "project" ? cwd : undefined);
156
- return { removed: skillDir };
178
+ return { removed };
157
179
  }
@@ -1,3 +1,15 @@
1
+ /**
2
+ * 按字节截断但不切开 UTF-8 多字节字符:边界落在字符中间时,把这个不完整的字符整个丢掉,
3
+ * 否则 toString("utf8") 会在末尾吐出 U+FFFD(中文预览截断处出现乱码方块)。
4
+ */
5
+ export function utf8Prefix(bytes: Buffer, maxBytes: number): Buffer {
6
+ if (bytes.length <= maxBytes) return bytes;
7
+ let end = maxBytes;
8
+ // bytes[end] 是第一个被丢掉的字节;它是续字节(10xxxxxx)说明字符从前面开始,最多回退 3 步
9
+ while (end > 0 && end > maxBytes - 4 && (bytes[end] & 0xc0) === 0x80) end -= 1;
10
+ return bytes.subarray(0, end);
11
+ }
12
+
1
13
  export interface SanitizedToolOutput {
2
14
  text: string;
3
15
  encodingLoss: boolean;
package/src/lib/theme.ts CHANGED
@@ -4,6 +4,8 @@
4
4
  * 两者独立持久化:配色 piweb.pebrelTheme,亮暗 piweb.theme。
5
5
  */
6
6
 
7
+ import { applyAllFonts } from './font';
8
+
7
9
  export type PebrelTheme =
8
10
  | 'silver-steel'
9
11
  | 'limestone-coal'
@@ -123,6 +125,7 @@ export function applyPebrelTheme(theme: PebrelTheme, mode?: ThemeMode): void {
123
125
  }
124
126
  applyLightDark(effectiveMode);
125
127
  applyFontSize();
128
+ applyAllFonts();
126
129
  try {
127
130
  localStorage.setItem(THEME_KEY, theme);
128
131
  localStorage.setItem(MODE_KEY, effectiveMode);
package/src/lib/types.ts CHANGED
@@ -84,7 +84,8 @@ export type WebEvent =
84
84
  type: "tool";
85
85
  id: string;
86
86
  name: string;
87
- args: unknown;
87
+ /** 进度更新(running 且带 partialResult)不重发 args,接收方沿用 start 时的值 */
88
+ args?: unknown;
88
89
  state: "running" | "done";
89
90
  partialResult?: string;
90
91
  result?: string;
@@ -146,7 +147,9 @@ export type WebEvent =
146
147
  | { type: "retry"; attempt: number; maxAttempts: number; message: string; ts: number }
147
148
  /** 项目生长:提交了一个 commit(pi 的一轮 / 用户两轮之间的修改 / 工作区基线) */
148
149
  | { type: "growth"; round: GrowthRound; ts: number }
149
- | { type: "growth_error"; message: string | null; ts: number };
150
+ | { type: "growth_error"; message: string | null; ts: number }
151
+ /** 项目生长要先在工作区建 git 仓库,等用户同意;request 为 null = 已回答(关掉弹窗) */
152
+ | { type: "growth_consent"; request: GrowthConsentRequest | null; ts: number };
150
153
 
151
154
  export interface WebStats {
152
155
  userMessages: number;
@@ -169,6 +172,13 @@ export interface WebStats {
169
172
  export type GrowthRoundKind = "round" | "user" | "baseline";
170
173
  export type GrowthRoundStatus = "done" | "aborted" | "error";
171
174
 
175
+ /** 工作区还不是 git 仓库根目录:not-git = 不在任何仓库里;nested = 是 parent 仓库的子目录(会嵌套建一个) */
176
+ export interface GrowthConsentRequest {
177
+ cwd: string;
178
+ reason: "not-git" | "nested";
179
+ parent?: string;
180
+ }
181
+
172
182
  export interface GrowthChange {
173
183
  status: "A" | "M" | "D" | "R";
174
184
  /** 相对工作区的路径("/" 分隔) */
@@ -266,6 +276,8 @@ export interface WebSnapshot {
266
276
  /** 全部持久用户消息的轮次(含上下文压缩后不再渲染的消息)。 */
267
277
  userTurns: { id: string; ts: number }[];
268
278
  growthError: string | null;
279
+ /** 等用户回答的建仓库请求;缺省按没有处理(兼容旧服务端) */
280
+ growthConsent?: GrowthConsentRequest | null;
269
281
  model?: { provider: string; id: string; name: string };
270
282
  thinkingLevel?: string;
271
283
  thinkingLevels: string[];
@@ -274,6 +286,8 @@ export interface WebSnapshot {
274
286
  customActiveTools?: string[] | null;
275
287
  /** 当前待应答的扩展请求;快照是权威集合,不重放已答请求。 */
276
288
  extensionUiRequests?: Extract<WebEvent, { type: "extension_ui" }>[];
289
+ /** 扩展通过 setStatus 设的页脚状态(key → 文本);缺省按空处理(兼容旧服务端) */
290
+ extensionStatuses?: Record<string, string>;
277
291
  tools: { active: string[]; all: { name: string; description?: string }[] };
278
292
  stats: WebStats | null;
279
293
  contextUsage: { tokens: number | null; contextWindow: number; percent: number | null } | null;
@@ -91,7 +91,6 @@ async function readPackageVersion(filePath: string): Promise<string> {
91
91
  export function createUpdateService({ root = APP_ROOT, run = runCommand, versions = getRuntimeVersions, env = process.env, platform = process.platform }: UpdateServiceOptions = {}) {
92
92
  let updateRunning = false;
93
93
  const commandEnv = { ...env };
94
- delete commandEnv.PI_WEB_PASSWORD;
95
94
 
96
95
  async function checkedRoot(): Promise<string> {
97
96
  return assertInstallation(root);
@@ -3,7 +3,11 @@
3
3
  * 登录页与 proxy / API 路由都用这些常量与纯函数。
4
4
  */
5
5
 
6
- export const SESSION_COOKIE = "piweb_session";
6
+ /**
7
+ * Cookie 名前缀。完整名字是 前缀 + base64url(sha256(权威)),
8
+ * 所以换域名或换端口后旧 Cookie 自然不再匹配,不需要额外清理。
9
+ */
10
+ export const COOKIE_PREFIX = "piweb_session_";
7
11
  export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60;
8
12
 
9
13
  /** 登录后跳转目标白名单:仅同源相对路径,防开放重定向 */
@@ -1,36 +1,153 @@
1
1
  /**
2
- * 会话认证(服务端):登录页用密码换一个无状态令牌(HMAC 派生),存 httpOnly Cookie。
3
- * Basic 认证保留为 API / 脚本兜底(bin/piweb.js 探活与 curl 依赖它)。
2
+ * 会话认证(服务端):启动令牌换一个无状态令牌(HMAC 派生),存 httpOnly Cookie。
3
+ * 这是唯一的认证路径 —— 密码登录已移除,Basic 也不再被接受。
4
+ *
5
+ * 设计要点(借鉴 deepseek-harness 的 browser-auth):
6
+ * 1. 签名密钥是独立随机值(web-secret.ts),与启动令牌无关 —— Cookie 泄露也无法离线爆破凭据;
7
+ * 2. Cookie 载荷带上权威(host:port),Cookie 名也由权威派生 —— 令牌不能跨域名/端口复用;
8
+ * 3. 载荷自带 issuedAt / expiresAt,服务端只验签名与时间窗,不存会话表;
9
+ * 登出要撤回的那一张、以及「让所有设备重新登录」的时间线,由调用方传入吊销清单(web-secret.ts)。
10
+ *
4
11
  * 纯常量与浏览器安全函数在 web-auth-shared(登录页引用,不能带上 node:crypto)。
5
12
  */
6
- import { createHmac } from "node:crypto";
7
- import { equalText } from "./auth";
8
- import { SESSION_COOKIE, SESSION_MAX_AGE_SECONDS, readCookieHeader, safeNextPath } from "./web-auth-shared";
13
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
14
+ import { COOKIE_PREFIX, SESSION_MAX_AGE_SECONDS, readCookieHeader, safeNextPath } from "./web-auth-shared";
9
15
 
10
- export { SESSION_COOKIE, SESSION_MAX_AGE_SECONDS, readCookieHeader, safeNextPath };
16
+ export { COOKIE_PREFIX, SESSION_MAX_AGE_SECONDS, readCookieHeader, safeNextPath };
11
17
 
12
- /** 校验登录密码(恒定时间比较) */
13
- export function validPassword(candidate: string, password: string): boolean {
14
- return equalText(candidate, password);
18
+ const COOKIE_PAYLOAD_VERSION = 1;
19
+ const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/;
20
+
21
+ export interface SessionCookiePayload {
22
+ version: typeof COOKIE_PAYLOAD_VERSION;
23
+ /** 签发时的 host:port;Cookie 名与签名都绑定它 */
24
+ authority: string;
25
+ issuedAt: number;
26
+ expiresAt: number;
27
+ }
28
+
29
+ function encodeBase64Url(value: Buffer): string {
30
+ return value.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
31
+ }
32
+
33
+ /** 严格解码:字符集、长度、往返一致性都要过,避免 Buffer 宽松解码吃掉非法字符 */
34
+ function decodeBase64Url(value: string): Buffer | undefined {
35
+ if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined;
36
+ const decoded = Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64");
37
+ return encodeBase64Url(decoded) === value ? decoded : undefined;
38
+ }
39
+
40
+ /**
41
+ * 认证是否启用:只看 PI_WEB_AUTH(1 / true / on / yes)。不再有第二个开关。
42
+ * 反代后面 PiWeb 仍绑回环,绑定位址判断不出「是否暴露」,所以这种情况必须显式打开。
43
+ */
44
+ export function authEnabled(env: Record<string, string | undefined> = process.env): boolean {
45
+ const flag = (env.PI_WEB_AUTH ?? "").trim().toLowerCase();
46
+ return flag === "1" || flag === "true" || flag === "on" || flag === "yes";
47
+ }
48
+
49
+ /** 请求的权威(host:port,小写)。带用户信息或解析失败一律无效。 */
50
+ export function requestAuthority(request: Request): string | undefined {
51
+ const host = request.headers.get("host") || new URL(request.url).host;
52
+ if (!host) return undefined;
53
+ try {
54
+ const parsed = new URL(`http://${host}`);
55
+ if (parsed.username || parsed.password) return undefined;
56
+ return parsed.host.toLowerCase();
57
+ } catch {
58
+ return undefined;
59
+ }
60
+ }
61
+
62
+ export function cookieNameForAuthority(authority: string): string {
63
+ return COOKIE_PREFIX + createHash("sha256").update(authority).digest("base64url");
64
+ }
65
+
66
+ function signature(secret: Buffer, body: string): Buffer {
67
+ return createHmac("sha256", secret).update(body).digest();
68
+ }
69
+
70
+ export function encodeSessionCookie(payload: SessionCookiePayload, secret: Buffer): string {
71
+ const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), "utf8"));
72
+ return `v1.${body}.${encodeBase64Url(signature(secret, body))}`;
73
+ }
74
+
75
+ export function decodeSessionCookie(value: string, secret: Buffer): SessionCookiePayload | undefined {
76
+ const parts = value.split(".");
77
+ if (parts.length !== 3 || parts[0] !== "v1") return undefined;
78
+ const body = parts[1];
79
+ const encoded = parts[2];
80
+ if (!body || !encoded) return undefined;
81
+ const actual = decodeBase64Url(encoded);
82
+ if (!actual) return undefined;
83
+ const expected = signature(secret, body);
84
+ if (actual.byteLength !== expected.byteLength || !timingSafeEqual(actual, expected)) return undefined;
85
+ const bodyBytes = decodeBase64Url(body);
86
+ if (!bodyBytes) return undefined;
87
+ let decoded: unknown;
88
+ try {
89
+ decoded = JSON.parse(bodyBytes.toString("utf8"));
90
+ } catch {
91
+ return undefined;
92
+ }
93
+ if (typeof decoded !== "object" || decoded === null || Array.isArray(decoded)) return undefined;
94
+ const payload = decoded as Partial<SessionCookiePayload>;
95
+ if (payload.version !== COOKIE_PAYLOAD_VERSION) return undefined;
96
+ if (typeof payload.authority !== "string") return undefined;
97
+ if (!Number.isSafeInteger(payload.issuedAt) || !Number.isSafeInteger(payload.expiresAt)) return undefined;
98
+ return payload as SessionCookiePayload;
99
+ }
100
+
101
+ /** 为一个权威签一张新 Cookie(名字、值、有效期一起给) */
102
+ export function createSessionCookie(authority: string, secret: Buffer, now = Date.now()) {
103
+ const expiresAt = now + SESSION_MAX_AGE_SECONDS * 1000;
104
+ const payload: SessionCookiePayload = { version: COOKIE_PAYLOAD_VERSION, authority, issuedAt: now, expiresAt };
105
+ return {
106
+ name: cookieNameForAuthority(authority),
107
+ value: encodeSessionCookie(payload, secret),
108
+ expiresAt,
109
+ maxAgeSeconds: SESSION_MAX_AGE_SECONDS,
110
+ };
111
+ }
112
+
113
+ /** 吊销清单(由 web-secret 从磁盘读出):notBefore 之前签发的全部作废;revoked 里是单张 Cookie 的指纹 */
114
+ export interface SessionRevocationList {
115
+ notBefore: number;
116
+ revoked: ReadonlyMap<string, number>;
117
+ }
118
+
119
+ /** 单张 Cookie 的指纹:吊销清单里只存它,不存 Cookie 本身 */
120
+ export function sessionCookieId(raw: string): string {
121
+ return createHash("sha256").update(raw).digest("base64url");
15
122
  }
16
123
 
17
- /** 令牌 = 过期时间戳 + 以密码为密钥的 HMAC。密码变更即全部令牌失效。 */
18
- export function createSessionToken(password: string, now = Date.now()): string {
19
- const expires = now + SESSION_MAX_AGE_SECONDS * 1000;
20
- return `${expires}.${sign(password, String(expires))}`;
124
+ /** 请求自带的、签名与权威都对的会话 Cookie(不看时间窗与吊销);登出时用它算出要撤的那一张 */
125
+ export function sessionCookieFromRequest(request: Request, secret: Buffer): { id: string; payload: SessionCookiePayload } | undefined {
126
+ const authority = requestAuthority(request);
127
+ if (!authority) return undefined;
128
+ const raw = readCookieHeader(request.headers.get("cookie"), cookieNameForAuthority(authority));
129
+ if (!raw) return undefined;
130
+ const payload = decodeSessionCookie(raw, secret);
131
+ if (!payload || payload.authority !== authority) return undefined;
132
+ return { id: sessionCookieId(raw), payload };
21
133
  }
22
134
 
23
- export function validSessionToken(token: string | undefined | null, password: string, now = Date.now()): boolean {
24
- if (!token) return false;
25
- const separator = token.indexOf(".");
26
- if (separator <= 0) return false;
27
- const expires = Number(token.slice(0, separator));
28
- if (!Number.isSafeInteger(expires) || expires <= now) return false;
29
- return equalText(token.slice(separator + 1), sign(password, String(expires)));
135
+ /** 校验请求自带的会话 Cookie:签名、权威、时间窗都要对,且没被吊销 */
136
+ export function validSessionCookie(request: Request, secret: Buffer, now = Date.now(), revocations?: SessionRevocationList): boolean {
137
+ const cookie = sessionCookieFromRequest(request, secret);
138
+ if (!cookie) return false;
139
+ const { payload } = cookie;
140
+ if (revocations && (payload.issuedAt < revocations.notBefore || revocations.revoked.has(cookie.id))) return false;
141
+ return payload.issuedAt <= now
142
+ && payload.expiresAt > now
143
+ && payload.expiresAt > payload.issuedAt
144
+ && payload.expiresAt - payload.issuedAt <= SESSION_MAX_AGE_SECONDS * 1000;
30
145
  }
31
146
 
32
- function sign(password: string, payload: string): string {
33
- return createHmac("sha256", `piweb-session:${password}`).update(payload).digest("base64url");
147
+ /** 清 Cookie 时要用同一个名字,否则删不掉 */
148
+ export function clearSessionCookie(request: Request) {
149
+ const authority = requestAuthority(request);
150
+ return { name: authority ? cookieNameForAuthority(authority) : COOKIE_PREFIX, value: "" };
34
151
  }
35
152
 
36
153
  export function isSecureRequest(request: Request): boolean {
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 启动令牌(借鉴 deepseek-harness 的 launch token)。
3
+ *
4
+ * 每个进程启动时生成一次 32 字节随机值,由启动器打印成 `?token=…` 链接。
5
+ * 浏览器用它换一张签名 Cookie,之后不再需要令牌。
6
+ * 令牌不接受出现在 API 路径上,也不接受走 Authorization 头 —— 只有根路径的
7
+ * GET 能消费它,这样令牌不会因为某个 API 客户端的日志而泄漏。
8
+ */
9
+ import { timingSafeEqual } from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { agentDir } from "./web-secret";
13
+
14
+ export const TOKEN_QUERY = "token";
15
+ export const TOKEN_BYTES = 32;
16
+
17
+ /** 恒定时间比较;两边任一为空(没配令牌)时一律无效 */
18
+ export function validLaunchToken(candidate: string | undefined, expected: string | undefined): boolean {
19
+ if (!candidate || !expected) return false;
20
+ const actual = Buffer.from(candidate, "utf8");
21
+ const wanted = Buffer.from(expected, "utf8");
22
+ return actual.byteLength === wanted.byteLength && timingSafeEqual(actual, wanted);
23
+ }
24
+
25
+ /**
26
+ * 正在运行的实例把自己的启动令牌留在 `<agentDir>/web-launch/<端口>.token`。
27
+ *
28
+ * 用途只有一个:再次运行启动器、发现这个端口上已有 PiWeb 时,能直接打开带令牌的登录链接,
29
+ * 而不是把用户扔到一个他手里没有凭据的登录页。文件和 web-secret 放在同一个目录、同样 0600:
30
+ * 读得到它的人本来就读得到签名密钥,不额外扩大暴露面。路径规则与 scripts/launcher.mjs 一致。
31
+ */
32
+ export function launchTokenFile(port: number | string): string {
33
+ return path.join(agentDir(), "web-launch", `${port}.token`);
34
+ }
35
+
36
+ /** 服务启动时调用:写下本进程的令牌,退出时摘掉(只摘自己写的那份) */
37
+ export function publishLaunchToken(env: Record<string, string | undefined> = process.env): void {
38
+ const token = env.PI_WEB_LAUNCH_TOKEN;
39
+ const port = env.PIWEB_PORT;
40
+ if (!token || !port) return;
41
+ const file = launchTokenFile(port);
42
+ try {
43
+ fs.mkdirSync(path.dirname(file), { recursive: true });
44
+ fs.writeFileSync(file, `${token}\n`, { mode: 0o600 });
45
+ } catch {
46
+ return; // 只是便利功能:写不进去就退回旧行为(打开裸地址)
47
+ }
48
+ process.once("exit", () => {
49
+ try {
50
+ if (fs.readFileSync(file, "utf8").trim() === token) fs.unlinkSync(file);
51
+ } catch {
52
+ /* 已经不在了 */
53
+ }
54
+ });
55
+ }