@baique/pi-web-sky 0.1.35 → 0.1.36

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 (294) hide show
  1. package/.next/BUILD_ID +1 -1
  2. package/.next/app-path-routes-manifest.json +16 -16
  3. package/.next/build-manifest.json +3 -3
  4. package/.next/react-loadable-manifest.json +1 -1
  5. package/.next/required-server-files.js +1 -1
  6. package/.next/required-server-files.json +1 -1
  7. package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  8. package/.next/server/app/_global-error.html +1 -1
  9. package/.next/server/app/_global-error.rsc +1 -1
  10. package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  12. package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  13. package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  14. package/.next/server/app/_not-found.html +1 -1
  15. package/.next/server/app/_not-found.rsc +3 -3
  16. package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
  17. package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
  18. package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  19. package/.next/server/app/api/agent/[id]/bash-output/route.js +1 -1
  20. package/.next/server/app/api/agent/[id]/events/route.js +1 -1
  21. package/.next/server/app/api/agent/[id]/route.js +1 -1
  22. package/.next/server/app/api/app-update/route.js +1 -1
  23. package/.next/server/app/api/auth/login/[provider]/route.js +1 -1
  24. package/.next/server/app/api/cwd/validate/route.js +1 -1
  25. package/.next/server/app/api/default-cwd/route.js +1 -1
  26. package/.next/server/app/api/file-index/route.js +1 -1
  27. package/.next/server/app/api/files/[...path]/route.js +1 -1
  28. package/.next/server/app/api/mcp/route.js +1 -1
  29. package/.next/server/app/api/models/route.js +1 -1
  30. package/.next/server/app/api/plugins/route.js +1 -1
  31. package/.next/server/app/api/project-trust/route.js +1 -1
  32. package/.next/server/app/api/search/route.js +1 -1
  33. package/.next/server/app/api/sessions/[id]/auto-name/route.js +1 -1
  34. package/.next/server/app/api/sessions/[id]/context/route.js +1 -1
  35. package/.next/server/app/api/sessions/[id]/entries/[entryId]/thinking/route.js +1 -1
  36. package/.next/server/app/api/sessions/[id]/export/route.js +1 -1
  37. package/.next/server/app/api/sessions/[id]/state/route.js +1 -1
  38. package/.next/server/app/api/sessions/[id]/todos/route.js +1 -1
  39. package/.next/server/app/api/sessions/route.js +1 -1
  40. package/.next/server/app/api/skills/check/route.js +1 -1
  41. package/.next/server/app/api/skills/check/route.js.nft.json +1 -1
  42. package/.next/server/app/api/skills/install/route.js +1 -1
  43. package/.next/server/app/api/skills/route.js +2 -31
  44. package/.next/server/app/api/skills/route.js.nft.json +1 -1
  45. package/.next/server/app/api/skills/update/route.js +1 -1
  46. package/.next/server/app/api/skills/update/route.js.nft.json +1 -1
  47. package/.next/server/app/api/worktrees/route.js +1 -1
  48. package/.next/server/app/index.html +1 -1
  49. package/.next/server/app/index.rsc +5 -5
  50. package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
  51. package/.next/server/app/index.segments/_full.segment.rsc +5 -5
  52. package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  53. package/.next/server/app/page.js +2 -2
  54. package/.next/server/app/page_client-reference-manifest.js +1 -1
  55. package/.next/server/app-paths-manifest.json +16 -16
  56. package/.next/server/chunks/2404.js +1 -1
  57. package/.next/server/chunks/3500.js +30 -0
  58. package/.next/server/chunks/4226.js +2 -2
  59. package/.next/server/chunks/542.js +10 -5
  60. package/.next/server/chunks/5794.js +1 -1
  61. package/.next/server/chunks/6429.js +1 -1
  62. package/.next/server/chunks/898.js +1 -1
  63. package/.next/server/middleware-build-manifest.js +1 -1
  64. package/.next/server/middleware-react-loadable-manifest.js +1 -1
  65. package/.next/server/pages/404.html +1 -1
  66. package/.next/server/pages/500.html +1 -1
  67. package/.next/static/chunks/5315.7e72d26a3ad3bd9a.js +4 -0
  68. package/.next/static/chunks/app/{layout-1d9a7a44fbc92d2e.js → layout-ebbc0d287267b07b.js} +1 -1
  69. package/.next/static/chunks/app/{page-6d6779a81a0a7639.js → page-e568623a44c67606.js} +2 -2
  70. package/.next/static/chunks/{webpack-fa4f40085b1ec1ac.js → webpack-2e4e348867a555cd.js} +1 -1
  71. package/.next/static/css/422c7dc0faa04d89.css +1 -0
  72. package/.next/trace +8 -9
  73. package/.next/trace-build +1 -1
  74. package/bin/pi-web-sky.js +15 -20
  75. package/lib/agent-client.test.mjs +64 -0
  76. package/lib/agent-client.ts +53 -0
  77. package/lib/agent-event-connection.test.mjs +193 -0
  78. package/lib/agent-event-connection.ts +202 -0
  79. package/lib/agent-event-stream.test.mjs +155 -0
  80. package/lib/agent-event-stream.ts +132 -0
  81. package/lib/agent-event-wire.test.mjs +278 -0
  82. package/lib/agent-event-wire.ts +107 -0
  83. package/lib/agent-phase.ts +24 -0
  84. package/lib/allowed-roots.ts +31 -0
  85. package/lib/ansi.test.mjs +52 -0
  86. package/lib/ansi.ts +211 -0
  87. package/lib/api-types.ts +139 -0
  88. package/lib/app-update.test.mjs +27 -0
  89. package/lib/app-update.ts +28 -0
  90. package/lib/atomic-file.test.mjs +37 -0
  91. package/lib/atomic-file.ts +34 -0
  92. package/lib/audit-session.ts +235 -0
  93. package/lib/bash-output.test.mjs +63 -0
  94. package/lib/bash-output.ts +53 -0
  95. package/lib/bg-image.ts +129 -0
  96. package/lib/board-events.ts +83 -0
  97. package/lib/board-purge.test.mjs +66 -0
  98. package/lib/board-purge.ts +65 -0
  99. package/lib/board-search.test.mjs +110 -0
  100. package/lib/board-search.ts +63 -0
  101. package/lib/board-store.test.mjs +542 -0
  102. package/lib/board-store.ts +794 -0
  103. package/lib/board-types.ts +96 -0
  104. package/lib/board-utils.ts +17 -0
  105. package/lib/bounded-form-data.test.mjs +54 -0
  106. package/lib/bounded-form-data.ts +49 -0
  107. package/lib/browser-notifications.test.mjs +167 -0
  108. package/lib/browser-notifications.ts +104 -0
  109. package/lib/chat-lazy-load.test.mjs +140 -0
  110. package/lib/chat-lazy-load.ts +64 -0
  111. package/lib/clipboard-paths.test.mjs +140 -0
  112. package/lib/clipboard-paths.ts +192 -0
  113. package/lib/clipboard.ts +18 -0
  114. package/lib/compaction-summary.test.mjs +44 -0
  115. package/lib/compaction-summary.ts +34 -0
  116. package/lib/custom-ui-terminal.test.mjs +32 -0
  117. package/lib/custom-ui-terminal.ts +27 -0
  118. package/lib/deepseek-pricing.test.mjs +120 -0
  119. package/lib/deepseek-pricing.ts +71 -0
  120. package/lib/directory-browser.test.mjs +60 -0
  121. package/lib/directory-browser.ts +83 -0
  122. package/lib/draft-stash.test.mjs +91 -0
  123. package/lib/draft-stash.ts +102 -0
  124. package/lib/draft-store.ts +105 -0
  125. package/lib/dropdown-direction.ts +27 -0
  126. package/lib/extension-command.test.mjs +63 -0
  127. package/lib/extension-command.ts +81 -0
  128. package/lib/file-access.test.mjs +31 -0
  129. package/lib/file-access.ts +59 -0
  130. package/lib/file-dirent.test.mjs +56 -0
  131. package/lib/file-dirent.ts +17 -0
  132. package/lib/file-explorer-state.test.mjs +63 -0
  133. package/lib/file-explorer-state.ts +36 -0
  134. package/lib/file-fuzzy.test.mjs +29 -0
  135. package/lib/file-fuzzy.ts +189 -0
  136. package/lib/file-links.test.mjs +92 -0
  137. package/lib/file-links.ts +148 -0
  138. package/lib/file-paths.ts +43 -0
  139. package/lib/file-types.test.mjs +34 -0
  140. package/lib/file-types.ts +73 -0
  141. package/lib/file-upload.test.mjs +70 -0
  142. package/lib/file-upload.ts +59 -0
  143. package/lib/file-viewer-state.test.mjs +20 -0
  144. package/lib/file-viewer-state.ts +15 -0
  145. package/lib/frontmatter.test.mjs +55 -0
  146. package/lib/frontmatter.ts +87 -0
  147. package/lib/git-changes.test.mjs +52 -0
  148. package/lib/git-changes.ts +232 -0
  149. package/lib/git-status.ts +47 -0
  150. package/lib/git-types.ts +29 -0
  151. package/lib/http-dispatcher.test.mjs +83 -0
  152. package/lib/http-dispatcher.ts +86 -0
  153. package/lib/i18n/format.test.mjs +19 -0
  154. package/lib/i18n/format.ts +60 -0
  155. package/lib/i18n/messages/en.ts +662 -0
  156. package/lib/i18n/messages/zh-CN.ts +661 -0
  157. package/lib/i18n/registry.test.mjs +32 -0
  158. package/lib/i18n/registry.ts +43 -0
  159. package/lib/i18n/types.ts +15 -0
  160. package/lib/image-attachments.test.mjs +27 -0
  161. package/lib/image-attachments.ts +56 -0
  162. package/lib/initial-navigation.test.mjs +65 -0
  163. package/lib/initial-navigation.ts +15 -0
  164. package/lib/markdown.test.mjs +104 -0
  165. package/lib/markdown.ts +347 -0
  166. package/lib/message-display.test.mjs +130 -0
  167. package/lib/message-display.ts +47 -0
  168. package/lib/model-catalog.test.mjs +327 -0
  169. package/lib/model-catalog.ts +414 -0
  170. package/lib/model-discovery-auth.ts +58 -0
  171. package/lib/model-discovery.test.mjs +50 -0
  172. package/lib/model-discovery.ts +75 -0
  173. package/lib/model-id-preference.ts +46 -0
  174. package/lib/model-scope.test.mjs +193 -0
  175. package/lib/model-scope.ts +198 -0
  176. package/lib/models-cache.test.mjs +122 -0
  177. package/lib/models-cache.ts +90 -0
  178. package/lib/models-config-store.test.mjs +200 -0
  179. package/lib/models-config-store.ts +84 -0
  180. package/lib/next-config-esm.test.mjs +14 -0
  181. package/lib/next-config.test.mjs +13 -0
  182. package/lib/node-version.test.mjs +33 -0
  183. package/lib/normalize.test.mjs +54 -0
  184. package/lib/normalize.ts +63 -0
  185. package/lib/npx.ts +62 -0
  186. package/lib/panel-layout.test.mjs +56 -0
  187. package/lib/panel-layout.ts +52 -0
  188. package/lib/patch.test.mjs +110 -0
  189. package/lib/patch.ts +134 -0
  190. package/lib/path-security.ts +42 -0
  191. package/lib/paths.test.mjs +58 -0
  192. package/lib/paths.ts +71 -0
  193. package/lib/pi-types.ts +183 -0
  194. package/lib/pi-web-options.test.mjs +63 -0
  195. package/lib/process-lifecycle.test.mjs +83 -0
  196. package/lib/project-command-env.test.mjs +298 -0
  197. package/lib/project-command-env.ts +129 -0
  198. package/lib/project-groups.test.mjs +60 -0
  199. package/lib/project-groups.ts +53 -0
  200. package/lib/project-identity.test.mjs +37 -0
  201. package/lib/project-identity.ts +26 -0
  202. package/lib/project-tree.test.mjs +115 -0
  203. package/lib/project-tree.ts +176 -0
  204. package/lib/project-trust.test.mjs +117 -0
  205. package/lib/project-trust.ts +48 -0
  206. package/lib/prompt-recovery.test.mjs +33 -0
  207. package/lib/prompt-recovery.ts +47 -0
  208. package/lib/provider-api-key-route.test.mjs +12 -0
  209. package/lib/provider-credential-store.test.mjs +126 -0
  210. package/lib/provider-credential-store.ts +114 -0
  211. package/lib/provider-listing-runtime.ts +37 -0
  212. package/lib/provider-listing.test.mjs +114 -0
  213. package/lib/provider-listing.ts +118 -0
  214. package/lib/provider-services.ts +64 -0
  215. package/lib/request-security.test.mjs +256 -0
  216. package/lib/request-security.ts +136 -0
  217. package/lib/rpc-manager-shutdown.test.mjs +372 -0
  218. package/lib/rpc-manager-widgets.test.mjs +342 -0
  219. package/lib/rpc-manager.test.mjs +139 -0
  220. package/lib/rpc-manager.ts +1703 -0
  221. package/lib/rpc-session-info.test.mjs +93 -0
  222. package/lib/session-delete.test.mjs +53 -0
  223. package/lib/session-delete.ts +86 -0
  224. package/lib/session-file-references-core.ts +82 -0
  225. package/lib/session-file-references.test.mjs +91 -0
  226. package/lib/session-file-references.ts +29 -0
  227. package/lib/session-path.test.mjs +24 -0
  228. package/lib/session-path.ts +11 -0
  229. package/lib/session-reader.pagination.test.mjs +115 -0
  230. package/lib/session-reader.test.mjs +446 -0
  231. package/lib/session-reader.ts +548 -0
  232. package/lib/session-resolve.test.mjs +65 -0
  233. package/lib/session-row-context-menu.test.mjs +46 -0
  234. package/lib/session-row-context-menu.ts +29 -0
  235. package/lib/session-scanner.test.mjs +151 -0
  236. package/lib/session-scanner.ts +238 -0
  237. package/lib/session-search.test.mjs +173 -0
  238. package/lib/session-search.ts +284 -0
  239. package/lib/session-stats.ts +26 -0
  240. package/lib/session-timing.test.mjs +107 -0
  241. package/lib/session-timing.ts +45 -0
  242. package/lib/session-title.test.mjs +284 -0
  243. package/lib/session-title.ts +255 -0
  244. package/lib/skill-frontmatter.test.mjs +75 -0
  245. package/lib/skill-frontmatter.ts +40 -0
  246. package/lib/skill-lock.test.mjs +182 -0
  247. package/lib/skill-lock.ts +146 -0
  248. package/lib/skill-updates.test.mjs +182 -0
  249. package/lib/skill-updates.ts +263 -0
  250. package/lib/skills-service.ts +16 -0
  251. package/lib/slash-display.test.mjs +122 -0
  252. package/lib/slash-display.ts +30 -0
  253. package/lib/sqlite-db.test.mjs +140 -0
  254. package/lib/sqlite-db.ts +205 -0
  255. package/lib/startup-preferences.test.mjs +130 -0
  256. package/lib/startup-preferences.ts +65 -0
  257. package/lib/streaming-message.test.mjs +212 -0
  258. package/lib/streaming-message.ts +151 -0
  259. package/lib/subagent-widget.test.mjs +87 -0
  260. package/lib/subagent-widget.ts +109 -0
  261. package/lib/sync-room-server.mjs +137 -0
  262. package/lib/task-card-store.test.mjs +309 -0
  263. package/lib/task-card-store.ts +559 -0
  264. package/lib/task-scheduler.ts +498 -0
  265. package/lib/task-store.test.mjs +216 -0
  266. package/lib/task-store.ts +271 -0
  267. package/lib/terminal-input.test.mjs +46 -0
  268. package/lib/terminal-input.ts +63 -0
  269. package/lib/terminal-manager.test.mjs +71 -0
  270. package/lib/terminal-manager.ts +245 -0
  271. package/lib/tool-execution-progress.test.mjs +32 -0
  272. package/lib/tool-execution-progress.ts +29 -0
  273. package/lib/tool-names.ts +25 -0
  274. package/lib/tool-preset-preference.test.mjs +72 -0
  275. package/lib/tool-preset-preference.ts +41 -0
  276. package/lib/tool-presets.test.mjs +56 -0
  277. package/lib/tool-presets.ts +42 -0
  278. package/lib/turn-written-files.test.mjs +137 -0
  279. package/lib/turn-written-files.ts +60 -0
  280. package/lib/types.ts +349 -0
  281. package/lib/wallpaper-settings.ts +191 -0
  282. package/lib/web-auth.test.mjs +50 -0
  283. package/lib/web-auth.ts +45 -0
  284. package/lib/workspace-memory.test.mjs +129 -0
  285. package/lib/workspace-memory.ts +98 -0
  286. package/lib/worktree.test.mjs +50 -0
  287. package/lib/worktree.ts +250 -0
  288. package/package.json +3 -1
  289. package/server.mjs +34 -0
  290. package/.next/server/chunks/7513.js +0 -30
  291. package/.next/static/chunks/5315.03de3c6abd8b283e.js +0 -4
  292. package/.next/static/css/cf25164cb7c04d95.css +0 -1
  293. /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_buildManifest.js +0 -0
  294. /package/.next/static/{Ud6Y2ONkGuPVFbo1xZQvk → EoBw3VYlygpr1EqlVOa5b}/_ssgManifest.js +0 -0
@@ -0,0 +1,110 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ async function loadSubject() {
5
+ return import("./patch.ts");
6
+ }
7
+
8
+ test("parses paired changes into split diff rows", async () => {
9
+ const { parseUnifiedPatch } = await loadSubject();
10
+ const files = parseUnifiedPatch(`--- a/demo.ts
11
+ +++ b/demo.ts
12
+ @@ -10,3 +10,3 @@
13
+ const keep = true;
14
+ -const value = "old";
15
+ +const value = "new";
16
+ done();
17
+ `);
18
+
19
+ assert.equal(files?.length, 1);
20
+ assert.equal(files?.[0].oldPath, "a/demo.ts");
21
+ assert.equal(files?.[0].newPath, "b/demo.ts");
22
+ assert.deepEqual(files?.[0].rows.filter((row) => row.type === "line"), [
23
+ {
24
+ type: "line",
25
+ left: { lineNo: 10, text: "const keep = true;", type: "context" },
26
+ right: { lineNo: 10, text: "const keep = true;", type: "context" },
27
+ },
28
+ {
29
+ type: "line",
30
+ left: { lineNo: 11, text: 'const value = "old";', type: "removed" },
31
+ right: { lineNo: 11, text: 'const value = "new";', type: "added" },
32
+ },
33
+ {
34
+ type: "line",
35
+ left: { lineNo: 12, text: "done();", type: "context" },
36
+ right: { lineNo: 12, text: "done();", type: "context" },
37
+ },
38
+ ]);
39
+ });
40
+
41
+ test("pads one-sided additions and removes timestamp suffixes from file paths", async () => {
42
+ const { parseUnifiedPatch } = await loadSubject();
43
+ const files = parseUnifiedPatch(`--- a/demo.ts\t2026-01-01
44
+ +++ b/demo.ts\t2026-01-02
45
+ @@ -1,1 +1,2 @@
46
+ first
47
+ +second
48
+ `);
49
+
50
+ assert.equal(files?.[0].oldPath, "a/demo.ts");
51
+ assert.equal(files?.[0].newPath, "b/demo.ts");
52
+ assert.deepEqual(files?.[0].rows.at(-1), {
53
+ type: "line",
54
+ left: { lineNo: null, text: "", type: "empty" },
55
+ right: { lineNo: 2, text: "second", type: "added" },
56
+ });
57
+ });
58
+
59
+ test("treats in-hunk lines that look like file headers as content", async () => {
60
+ const { parseUnifiedPatch } = await loadSubject();
61
+ // The added line's content starts with "++ " and the removed line's with
62
+ // "-- ", so the raw diff lines are "+++ ..." and "--- ...". They must be
63
+ // parsed as one file's added/removed content, not as new file headers.
64
+ const files = parseUnifiedPatch(`--- a/notes.md
65
+ +++ b/notes.md
66
+ @@ -1,2 +1,2 @@
67
+ -- old bullet
68
+ ++ new bullet
69
+ tail
70
+ `);
71
+
72
+ assert.equal(files?.length, 1);
73
+ assert.equal(files?.[0].newPath, "b/notes.md");
74
+ const lines = files?.[0].rows.filter((row) => row.type === "line");
75
+ assert.deepEqual(lines?.[0], {
76
+ type: "line",
77
+ left: { lineNo: 1, text: "- old bullet", type: "removed" },
78
+ right: { lineNo: 1, text: "+ new bullet", type: "added" },
79
+ });
80
+ assert.deepEqual(lines?.[1], {
81
+ type: "line",
82
+ left: { lineNo: 2, text: "tail", type: "context" },
83
+ right: { lineNo: 2, text: "tail", type: "context" },
84
+ });
85
+ });
86
+
87
+ test("parses multiple files, resuming header detection after each hunk", async () => {
88
+ const { parseUnifiedPatch } = await loadSubject();
89
+ const files = parseUnifiedPatch(`--- a/one.ts
90
+ +++ b/one.ts
91
+ @@ -1 +1 @@
92
+ -a
93
+ +b
94
+ --- a/two.ts
95
+ +++ b/two.ts
96
+ @@ -1 +1 @@
97
+ -c
98
+ +d
99
+ `);
100
+
101
+ assert.equal(files?.length, 2);
102
+ assert.equal(files?.[0].newPath, "b/one.ts");
103
+ assert.equal(files?.[1].newPath, "b/two.ts");
104
+ });
105
+
106
+ test("returns null for text without diff lines", async () => {
107
+ const { parseUnifiedPatch } = await loadSubject();
108
+
109
+ assert.equal(parseUnifiedPatch("not a patch"), null);
110
+ });
package/lib/patch.ts ADDED
@@ -0,0 +1,134 @@
1
+ export type SplitDiffCellType = "context" | "removed" | "added" | "empty";
2
+
3
+ export interface SplitDiffCell {
4
+ lineNo: number | null;
5
+ text: string;
6
+ type: SplitDiffCellType;
7
+ }
8
+
9
+ export type SplitDiffRow =
10
+ | { type: "hunk"; text: string }
11
+ | { type: "line"; left: SplitDiffCell; right: SplitDiffCell };
12
+
13
+ export interface SplitDiffFile {
14
+ oldPath?: string;
15
+ newPath?: string;
16
+ rows: SplitDiffRow[];
17
+ }
18
+
19
+ interface PendingChangeLine {
20
+ lineNo: number;
21
+ text: string;
22
+ }
23
+
24
+ export function parseUnifiedPatch(text: string): SplitDiffFile[] | null {
25
+ const files: SplitDiffFile[] = [];
26
+ let current: SplitDiffFile | null = null;
27
+ let pendingOldPath: string | undefined;
28
+ let oldLineNo = 0;
29
+ let newLineNo = 0;
30
+ // Lines still expected in the current hunk body, from the @@ header counts.
31
+ // While either is positive we are inside a hunk, where a line starting with
32
+ // "--- "/"+++ " is a removed/added content line (e.g. "-- x"/"++ x"), not a
33
+ // file header — checking for headers there splits one file into bogus extras.
34
+ let hunkOldRemaining = 0;
35
+ let hunkNewRemaining = 0;
36
+ let removed: PendingChangeLine[] = [];
37
+ let added: PendingChangeLine[] = [];
38
+
39
+ const emptyCell = (): SplitDiffCell => ({ lineNo: null, text: "", type: "empty" });
40
+ const flushChanges = () => {
41
+ if (!current) {
42
+ removed = [];
43
+ added = [];
44
+ return;
45
+ }
46
+ const count = Math.max(removed.length, added.length);
47
+ for (let i = 0; i < count; i++) {
48
+ const left = removed[i]
49
+ ? { lineNo: removed[i].lineNo, text: removed[i].text, type: "removed" as const }
50
+ : emptyCell();
51
+ const right = added[i]
52
+ ? { lineNo: added[i].lineNo, text: added[i].text, type: "added" as const }
53
+ : emptyCell();
54
+ current.rows.push({ type: "line", left, right });
55
+ }
56
+ removed = [];
57
+ added = [];
58
+ };
59
+
60
+ for (const line of text.split(/\r?\n/)) {
61
+ const insideHunk = hunkOldRemaining > 0 || hunkNewRemaining > 0;
62
+
63
+ // File headers only appear between hunks, never inside a hunk body.
64
+ if (!insideHunk) {
65
+ if (line.startsWith("--- ")) {
66
+ flushChanges();
67
+ pendingOldPath = cleanPatchPath(line.slice(4));
68
+ continue;
69
+ }
70
+
71
+ if (line.startsWith("+++ ")) {
72
+ flushChanges();
73
+ current = { oldPath: pendingOldPath, newPath: cleanPatchPath(line.slice(4)), rows: [] };
74
+ files.push(current);
75
+ continue;
76
+ }
77
+ }
78
+
79
+ const hunk = line.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/);
80
+ if (hunk) {
81
+ if (!current) {
82
+ current = { rows: [] };
83
+ files.push(current);
84
+ }
85
+ flushChanges();
86
+ oldLineNo = Number(hunk[1]);
87
+ newLineNo = Number(hunk[3]);
88
+ hunkOldRemaining = hunk[2] === undefined ? 1 : Number(hunk[2]);
89
+ hunkNewRemaining = hunk[4] === undefined ? 1 : Number(hunk[4]);
90
+ current.rows.push({ type: "hunk", text: line });
91
+ continue;
92
+ }
93
+
94
+ if (!current) continue;
95
+
96
+ if (line.startsWith("\\ ")) {
97
+ flushChanges();
98
+ current.rows.push({ type: "hunk", text: line });
99
+ continue;
100
+ }
101
+
102
+ const prefix = line[0];
103
+ const content = line.slice(1);
104
+
105
+ if (prefix === " ") {
106
+ flushChanges();
107
+ current.rows.push({
108
+ type: "line",
109
+ left: { lineNo: oldLineNo++, text: content, type: "context" },
110
+ right: { lineNo: newLineNo++, text: content, type: "context" },
111
+ });
112
+ if (hunkOldRemaining > 0) hunkOldRemaining--;
113
+ if (hunkNewRemaining > 0) hunkNewRemaining--;
114
+ } else if (prefix === "-") {
115
+ removed.push({ lineNo: oldLineNo++, text: content });
116
+ if (hunkOldRemaining > 0) hunkOldRemaining--;
117
+ } else if (prefix === "+") {
118
+ added.push({ lineNo: newLineNo++, text: content });
119
+ if (hunkNewRemaining > 0) hunkNewRemaining--;
120
+ } else if (line !== "") {
121
+ flushChanges();
122
+ current.rows.push({ type: "hunk", text: line });
123
+ }
124
+ }
125
+
126
+ flushChanges();
127
+
128
+ const parsed = files.filter((file) => file.rows.some((row) => row.type === "line"));
129
+ return parsed.length > 0 ? parsed : null;
130
+ }
131
+
132
+ function cleanPatchPath(path: string): string {
133
+ return path.split("\t")[0].trim();
134
+ }
@@ -0,0 +1,42 @@
1
+ import { realpathSync } from "fs";
2
+ import path from "path";
3
+ import { isWindowsAbsolutePath } from "./paths";
4
+
5
+ /**
6
+ * Lexical containment check. Accepts either canonical form on both sides: it
7
+ * re-resolves through path.win32/path.posix and case-folds on Windows, so
8
+ * separator style and drive-letter case never decide the answer.
9
+ */
10
+ export function isPathWithinRoots(target: string, roots: Set<string>): boolean {
11
+ for (const root of roots) {
12
+ const useWindowsRules = isWindowsAbsolutePath(target) || isWindowsAbsolutePath(root);
13
+ const resolver = useWindowsRules ? path.win32 : path;
14
+ const sep = useWindowsRules ? "\\" : path.sep;
15
+ const normalized = resolver.resolve(target);
16
+ const normalizedRoot = resolver.resolve(root);
17
+ const comparable = useWindowsRules ? normalized.toLowerCase() : normalized;
18
+ const comparableRoot = useWindowsRules ? normalizedRoot.toLowerCase() : normalizedRoot;
19
+ const rootWithSep = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep;
20
+ if (comparable === comparableRoot || comparable.startsWith(rootWithSep)) return true;
21
+ }
22
+ return false;
23
+ }
24
+
25
+ export function isExistingPathWithinRoots(target: string, roots: Set<string>): boolean {
26
+ let realTarget: string;
27
+ try {
28
+ realTarget = realpathSync(target);
29
+ } catch {
30
+ return false;
31
+ }
32
+
33
+ const realRoots = new Set<string>();
34
+ for (const root of roots) {
35
+ try {
36
+ realRoots.add(realpathSync(root));
37
+ } catch {
38
+ // Ignore stale roots derived from removed sessions or worktrees.
39
+ }
40
+ }
41
+ return isPathWithinRoots(realTarget, realRoots);
42
+ }
@@ -0,0 +1,58 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+
4
+ const isWindows = process.platform === "win32";
5
+
6
+ async function loadSubject() {
7
+ const { createJiti } = await import("jiti");
8
+ return createJiti(import.meta.url).import("./paths.ts");
9
+ }
10
+
11
+ test("toNativePath converts git's POSIX output to native separators", async () => {
12
+ const { toNativePath } = await loadSubject();
13
+ if (isWindows) {
14
+ // The regression this guards: `git rev-parse --path-format=absolute` prints
15
+ // `D:/repo` on Windows, which never string-compares equal to a session cwd.
16
+ assert.equal(toNativePath("D:/repo/sub"), "D:\\repo\\sub");
17
+ assert.equal(toNativePath("D:\\repo\\sub"), "D:\\repo\\sub");
18
+ } else {
19
+ assert.equal(toNativePath("/repo/sub"), "/repo/sub");
20
+ }
21
+ assert.equal(toNativePath(""), "");
22
+ });
23
+
24
+ test("samePath ignores separator style and Windows case", async () => {
25
+ const { samePath } = await loadSubject();
26
+ assert.equal(samePath("/a/b", "/a/b"), true);
27
+ assert.equal(samePath("/a/b/", "/a/b"), true, "trailing separators must not matter");
28
+ assert.equal(samePath("/a/./b", "/a/b"), true, "dot segments must not matter");
29
+ assert.equal(samePath("/a/b", "/a/c"), false);
30
+ assert.equal(samePath("", ""), true);
31
+ assert.equal(samePath("", "/a"), false);
32
+
33
+ if (isWindows) {
34
+ assert.equal(samePath("D:/repo", "D:\\repo"), true, "separator style must not matter");
35
+ assert.equal(samePath("d:\\repo", "D:\\repo"), true, "drive-letter case must not matter");
36
+ assert.equal(samePath("D:\\Repo\\Sub", "d:/repo/sub"), true);
37
+ assert.equal(samePath("D:\\repo\\", "D:/repo"), true);
38
+ assert.equal(samePath("D:\\repo", "D:\\repo2"), false);
39
+ } else {
40
+ // POSIX is case-sensitive and backslash is a legal filename character.
41
+ assert.equal(samePath("/Repo", "/repo"), false);
42
+ assert.equal(samePath("/a\\b", "/a/b"), false);
43
+ }
44
+ });
45
+
46
+ test("toSlashPath normalizes to forward slashes", async () => {
47
+ const { toSlashPath } = await loadSubject();
48
+ assert.equal(toSlashPath("D:\\repo\\sub"), "D:/repo/sub");
49
+ assert.equal(toSlashPath("/repo/sub"), "/repo/sub");
50
+ });
51
+
52
+ test("isWindowsAbsolutePath recognizes drive and UNC paths", async () => {
53
+ const { isWindowsAbsolutePath } = await loadSubject();
54
+ assert.equal(isWindowsAbsolutePath("D:\\repo"), true);
55
+ assert.equal(isWindowsAbsolutePath("d:/repo"), true);
56
+ assert.equal(isWindowsAbsolutePath("\\\\server\\share"), true);
57
+ assert.equal(isWindowsAbsolutePath("relative/path"), false);
58
+ });
package/lib/paths.ts ADDED
@@ -0,0 +1,71 @@
1
+ import { normalize, parse, sep } from "path";
2
+
3
+ // ============================================================================
4
+ // Path primitives.
5
+ //
6
+ // Two canonical forms coexist deliberately — pick by where the path is going:
7
+ //
8
+ // toNativePath() Native separators (`D:\repo` on Windows). Use for anything
9
+ // that reaches fs/path APIs, gets compared against a session
10
+ // cwd, or is shown to the user. This is the form pi records
11
+ // cwds in, so it is the default for user-facing paths.
12
+ //
13
+ // toSlashPath() Forward slashes (`D:/repo`). Use only for internal,
14
+ // never-displayed bookkeeping — the allowed-roots set, and
15
+ // separator-insensitive text matching. Containment checks
16
+ // re-normalize their inputs anyway (see path-security.ts),
17
+ // so this form is about consistent keys, not correctness.
18
+ //
19
+ // Comparison always goes through samePath()/isPathWithinRoots(), never `===`:
20
+ // git emits POSIX-style paths even on Windows, and Windows itself is
21
+ // case-insensitive, so raw string equality silently fails on both counts.
22
+ // ============================================================================
23
+
24
+ const WINDOWS_ABSOLUTE_RE = /^[a-zA-Z]:[\\/]/;
25
+
26
+ export function isWindowsAbsolutePath(filePath: string): boolean {
27
+ return WINDOWS_ABSOLUTE_RE.test(filePath) || filePath.startsWith("\\\\") || filePath.startsWith("//");
28
+ }
29
+
30
+ /**
31
+ * Convert a path to native separators. Chiefly for git output: git prints
32
+ * POSIX-style absolute paths even on Windows (`D:/repo/sub`), which never
33
+ * string-compares equal to the native paths Node and pi produce.
34
+ *
35
+ * Only pass paths — a branch name like `feature/x` would become `feature\x`.
36
+ */
37
+ export function toNativePath(p: string): string {
38
+ if (!p || process.platform !== "win32") return p;
39
+ return normalize(p);
40
+ }
41
+
42
+ /** Convert a path to forward slashes. See the form guidance above. */
43
+ export function toSlashPath(p: string): string {
44
+ return p.replace(/\\/g, "/");
45
+ }
46
+
47
+ function normalizeForComparison(p: string): string {
48
+ const normalized = normalize(toNativePath(p));
49
+ const rootLength = parse(normalized).root.length;
50
+ let end = normalized.length;
51
+ while (end > rootLength && normalized[end - 1] === sep) end--;
52
+ return normalized.slice(0, end);
53
+ }
54
+
55
+ /**
56
+ * Whether two paths denote the same location, tolerating separator style and —
57
+ * on Windows, where the filesystem is case-insensitive — case, including the
58
+ * drive letter (`d:\repo` vs `D:\repo`).
59
+ *
60
+ * Compares lexically: callers wanting symlinks resolved should realpath first.
61
+ */
62
+ export function samePath(a: string, b: string): boolean {
63
+ if (a === b) return true;
64
+ if (!a || !b) return false;
65
+ const normalizedA = normalizeForComparison(a);
66
+ const normalizedB = normalizeForComparison(b);
67
+ if (process.platform === "win32") {
68
+ return normalizedA.toLowerCase() === normalizedB.toLowerCase();
69
+ }
70
+ return normalizedA === normalizedB;
71
+ }
@@ -0,0 +1,183 @@
1
+ import type {
2
+ AgentSessionEvent,
3
+ BashOperations,
4
+ SessionManager,
5
+ SettingsManager,
6
+ SlashCommandInfo,
7
+ Theme,
8
+ } from "@earendil-works/pi-coding-agent";
9
+ import type { AgentMessage as PiAgentMessage } from "@earendil-works/pi-agent-core";
10
+
11
+ export interface ContextUsage {
12
+ percent: number | null;
13
+ contextWindow: number;
14
+ tokens: number | null;
15
+ }
16
+
17
+ export interface ModelLike {
18
+ id: string;
19
+ provider: string;
20
+ }
21
+
22
+ export interface ToolInfo {
23
+ name: string;
24
+ description: string;
25
+ }
26
+
27
+ export interface NavigateTreeResult {
28
+ editorText?: string;
29
+ cancelled: boolean;
30
+ aborted?: boolean;
31
+ }
32
+
33
+ export interface SessionStatsInfo {
34
+ sessionFile?: string;
35
+ sessionId: string;
36
+ sessionName?: string;
37
+ userMessages: number;
38
+ assistantMessages: number;
39
+ toolCalls: number;
40
+ toolResults: number;
41
+ totalMessages: number;
42
+ tokens: {
43
+ input: number;
44
+ output: number;
45
+ cacheRead: number;
46
+ cacheWrite: number;
47
+ total: number;
48
+ };
49
+ cost: number;
50
+ contextUsage?: ContextUsage;
51
+ /** Estimated active time across all entries in the session file. */
52
+ totalActiveMs?: number;
53
+ }
54
+
55
+ interface PromptTemplateLike {
56
+ name: string;
57
+ description?: string;
58
+ sourceInfo: SlashCommandInfo["sourceInfo"];
59
+ }
60
+
61
+ interface SkillLike {
62
+ name: string;
63
+ description?: string;
64
+ sourceInfo: SlashCommandInfo["sourceInfo"];
65
+ }
66
+
67
+ interface ResourceLoaderLike {
68
+ getSkills(): { skills: SkillLike[] };
69
+ }
70
+
71
+ interface ExtensionRunnerLike {
72
+ getRegisteredCommands(): Array<{
73
+ invocationName: string;
74
+ description?: string;
75
+ sourceInfo: SlashCommandInfo["sourceInfo"];
76
+ }>;
77
+ emit?(event: { type: "session_shutdown"; reason: "quit" }): Promise<unknown>;
78
+ setUIContext?(uiContext?: unknown, mode?: "tui" | "rpc" | "json" | "print"): void;
79
+ }
80
+
81
+ type DialogOptionsLike = {
82
+ signal?: AbortSignal;
83
+ timeout?: number;
84
+ };
85
+
86
+ type WidgetOptionsLike = {
87
+ placement?: "aboveEditor" | "belowEditor";
88
+ };
89
+
90
+ export interface ExtensionUiContextLike {
91
+ select(title: string, options: string[], opts?: DialogOptionsLike): Promise<string | undefined>;
92
+ confirm(title: string, message: string, opts?: DialogOptionsLike): Promise<boolean>;
93
+ input(title: string, placeholder?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
94
+ editor(title: string, prefill?: string, opts?: DialogOptionsLike): Promise<string | undefined>;
95
+ notify(message: string, type?: "info" | "warning" | "error"): void;
96
+ onTerminalInput(): () => void;
97
+ setStatus(key: string, text: string | undefined): void;
98
+ setWorkingMessage(message?: string): void;
99
+ setWorkingVisible(visible: boolean): void;
100
+ setWorkingIndicator(options?: { frames?: string[]; intervalMs?: number }): void;
101
+ setHiddenThinkingLabel(label?: string): void;
102
+ setWidget(key: string, content: string[] | ((...args: never[]) => unknown) | undefined, options?: WidgetOptionsLike): void;
103
+ setFooter(factory: unknown): void;
104
+ setHeader(factory: unknown): void;
105
+ setTitle(title: string): void;
106
+ custom<T = unknown>(...args: unknown[]): Promise<T>;
107
+ pasteToEditor(text: string): void;
108
+ setEditorText(text: string): void;
109
+ getEditorText(): string;
110
+ addAutocompleteProvider(): void;
111
+ setEditorComponent(): void;
112
+ getEditorComponent(): undefined;
113
+ readonly theme: Theme;
114
+ getAllThemes(): unknown[];
115
+ getTheme(name: string): undefined;
116
+ setTheme(theme: unknown): { success: boolean; error?: string };
117
+ getToolsExpanded(): boolean;
118
+ setToolsExpanded(expanded: boolean): void;
119
+ }
120
+
121
+ export interface AgentSessionLike {
122
+ readonly sessionId: string;
123
+ readonly sessionFile: string | undefined;
124
+ readonly isStreaming: boolean;
125
+ readonly isCompacting: boolean;
126
+ readonly autoCompactionEnabled: boolean;
127
+ readonly autoRetryEnabled: boolean;
128
+ readonly model: ModelLike | undefined;
129
+ readonly modelRuntime: {
130
+ getModel: (provider: string, modelId: string) => ModelLike | undefined;
131
+ refresh: (options?: { allowNetwork?: boolean }) => Promise<unknown>;
132
+ };
133
+ readonly sessionManager: SessionManager;
134
+ readonly settingsManager: SettingsManager;
135
+ readonly agent: {
136
+ state?: {
137
+ systemPrompt?: string;
138
+ thinkingLevel?: string;
139
+ streamingMessage?: PiAgentMessage;
140
+ };
141
+ };
142
+ readonly extensionRunner: ExtensionRunnerLike;
143
+ readonly promptTemplates: readonly PromptTemplateLike[];
144
+ readonly resourceLoader: ResourceLoaderLike;
145
+
146
+ readonly bindExtensions?: unknown;
147
+ dispose(): void;
148
+ reload(options?: { beforeSessionStart?: () => void | Promise<void> }): Promise<void>;
149
+ subscribe(listener: (event: AgentSessionEvent) => void): () => void;
150
+ prompt(text: string, options?: {
151
+ images?: Array<{ type: "image"; data: string; mimeType: string }>;
152
+ streamingBehavior?: "steer" | "followUp";
153
+ source?: "interactive" | "rpc";
154
+ preflightResult?: (success: boolean) => void;
155
+ }): Promise<void>;
156
+ abort(): Promise<void>;
157
+ executeBash(command: string, onChunk?: (chunk: string) => void, options?: {
158
+ excludeFromContext?: boolean;
159
+ operations?: BashOperations;
160
+ }): Promise<{ output: string; exitCode?: number; cancelled?: boolean; truncated?: boolean; fullOutputPath?: string }>;
161
+ abortBash(): void;
162
+ readonly isBashRunning: boolean;
163
+ setModel(model: ModelLike): Promise<void>;
164
+ navigateTree(targetId: string, options?: { summarize?: boolean }): Promise<NavigateTreeResult>;
165
+ setThinkingLevel(level: string): void;
166
+ compact(customInstructions?: string): Promise<unknown>;
167
+ setSessionName(name: string): void;
168
+ getSessionStats(): Omit<SessionStatsInfo, "sessionName">;
169
+ getLastAssistantText(): string | undefined;
170
+ setAutoCompactionEnabled(enabled: boolean): void;
171
+ setAutoRetryEnabled(enabled: boolean): void;
172
+ steer(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
173
+ followUp(text: string, images?: Array<{ type: "image"; data: string; mimeType: string }>): Promise<void>;
174
+ readonly pendingMessageCount: number;
175
+ getSteeringMessages(): readonly string[];
176
+ getFollowUpMessages(): readonly string[];
177
+ clearQueue(): { steering: string[]; followUp: string[] };
178
+ getAllTools(): ToolInfo[];
179
+ getActiveToolNames(): string[];
180
+ setActiveToolsByName(names: string[]): void;
181
+ abortCompaction(): void;
182
+ getContextUsage(): ContextUsage | undefined;
183
+ }
@@ -0,0 +1,63 @@
1
+ import assert from "node:assert/strict";
2
+ import { createRequire } from "node:module";
3
+ import test from "node:test";
4
+
5
+ const require = createRequire(import.meta.url);
6
+ const { parseLaunchOptions } = require("../bin/pi-web-options.js");
7
+
8
+ test("opens the browser by default", () => {
9
+ assert.deepEqual(parseLaunchOptions([], {}), {
10
+ port: "30141",
11
+ hostname: "127.0.0.1",
12
+ openBrowser: true,
13
+ });
14
+ });
15
+
16
+ test("supports the no-open CLI option", () => {
17
+ assert.equal(parseLaunchOptions(["--no-open"], {}).openBrowser, false);
18
+ });
19
+
20
+ test("supports truthy PI_WEB_NO_OPEN values", () => {
21
+ for (const value of ["1", "true", "TRUE", "yes", "on"]) {
22
+ assert.equal(parseLaunchOptions([], { PI_WEB_NO_OPEN: value }).openBrowser, false);
23
+ }
24
+ });
25
+
26
+ test("does not disable browser opening for false PI_WEB_NO_OPEN values", () => {
27
+ for (const value of ["0", "false", "off", ""]) {
28
+ assert.equal(parseLaunchOptions([], { PI_WEB_NO_OPEN: value }).openBrowser, true);
29
+ }
30
+ });
31
+
32
+ test("preserves port and hostname options", () => {
33
+ assert.deepEqual(
34
+ parseLaunchOptions(["-p", "8080", "-H", "0.0.0.0"], {}),
35
+ {
36
+ port: "8080",
37
+ hostname: "0.0.0.0",
38
+ openBrowser: true,
39
+ },
40
+ );
41
+ });
42
+
43
+ test("rejects port values that could inject cmd arguments", () => {
44
+ assert.throws(
45
+ () => parseLaunchOptions(["-p", "30141&whoami"], {}),
46
+ /Port must be a non-negative integer/,
47
+ );
48
+ assert.throws(
49
+ () => parseLaunchOptions([], { PORT: "30141&whoami" }),
50
+ /Port must be a non-negative integer/,
51
+ );
52
+ });
53
+
54
+ test("supports PI_WEB_HOSTNAME without trusting the ambient system HOSTNAME", () => {
55
+ assert.equal(
56
+ parseLaunchOptions([], { HOSTNAME: "container-id" }).hostname,
57
+ "127.0.0.1",
58
+ );
59
+ assert.equal(
60
+ parseLaunchOptions([], { PI_WEB_HOSTNAME: "0.0.0.0" }).hostname,
61
+ "0.0.0.0",
62
+ );
63
+ });