pim-agent 0.0.1 → 0.9.0

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/LICENSE +21 -0
  2. package/README.md +293 -7
  3. package/bin/pim.ts +151 -0
  4. package/package.json +82 -4
  5. package/packages/core/src/attachments/AttachmentStore.ts +99 -0
  6. package/packages/core/src/attachments/Attachments.ts +69 -0
  7. package/packages/core/src/extensions/CoreExtensions.ts +51 -0
  8. package/packages/core/src/extensions/apply-patch/coordinator.ts +53 -0
  9. package/packages/core/src/extensions/apply-patch/executor.ts +550 -0
  10. package/packages/core/src/extensions/apply-patch/index.ts +69 -0
  11. package/packages/core/src/extensions/apply-patch/matcher.ts +47 -0
  12. package/packages/core/src/extensions/apply-patch/model.ts +39 -0
  13. package/packages/core/src/extensions/apply-patch/parser.ts +379 -0
  14. package/packages/core/src/extensions/apply-patch/render.ts +216 -0
  15. package/packages/core/src/extensions/apply-patch/schema.ts +37 -0
  16. package/packages/core/src/extensions/apply-patch/types.ts +30 -0
  17. package/packages/core/src/extensions/bash/capture.ts +86 -0
  18. package/packages/core/src/extensions/bash/format.ts +110 -0
  19. package/packages/core/src/extensions/bash/image.ts +31 -0
  20. package/packages/core/src/extensions/bash/index.ts +88 -0
  21. package/packages/core/src/extensions/bash/render.ts +31 -0
  22. package/packages/core/src/extensions/bash/run.ts +221 -0
  23. package/packages/core/src/extensions/bash/schema.ts +64 -0
  24. package/packages/core/src/extensions/edit/edit.ts +366 -0
  25. package/packages/core/src/extensions/edit/index.ts +36 -0
  26. package/packages/core/src/extensions/edit/render.ts +16 -0
  27. package/packages/core/src/extensions/edit/schema.ts +37 -0
  28. package/packages/core/src/extensions/glob/glob.ts +34 -0
  29. package/packages/core/src/extensions/glob/index.ts +83 -0
  30. package/packages/core/src/extensions/glob/render.ts +72 -0
  31. package/packages/core/src/extensions/glob/schema.ts +66 -0
  32. package/packages/core/src/extensions/grep/grep.ts +256 -0
  33. package/packages/core/src/extensions/grep/index.ts +114 -0
  34. package/packages/core/src/extensions/grep/render.ts +271 -0
  35. package/packages/core/src/extensions/grep/schema.ts +92 -0
  36. package/packages/core/src/extensions/read/ImageMemory.ts +42 -0
  37. package/packages/core/src/extensions/read/image.ts +57 -0
  38. package/packages/core/src/extensions/read/index.ts +88 -0
  39. package/packages/core/src/extensions/read/read.ts +191 -0
  40. package/packages/core/src/extensions/read/render.ts +58 -0
  41. package/packages/core/src/extensions/read/schema.ts +49 -0
  42. package/packages/core/src/extensions/subagent/index.ts +33 -0
  43. package/packages/core/src/extensions/subagent/render.ts +127 -0
  44. package/packages/core/src/extensions/subagent/schema.ts +9 -0
  45. package/packages/core/src/extensions/subagent/subagent.ts +419 -0
  46. package/packages/core/src/extensions/system-prompt/index.ts +28 -0
  47. package/packages/core/src/extensions/system-prompt/prompt.ts +228 -0
  48. package/packages/core/src/extensions/todo/index.ts +123 -0
  49. package/packages/core/src/extensions/todo/render.ts +156 -0
  50. package/packages/core/src/extensions/todo/schema.ts +24 -0
  51. package/packages/core/src/extensions/todo/todo.ts +185 -0
  52. package/packages/core/src/extensions/web-fetch/JinaReaderClient.ts +187 -0
  53. package/packages/core/src/extensions/web-fetch/WebViewFetchClient.ts +183 -0
  54. package/packages/core/src/extensions/web-fetch/WebViewMarkdownSnapshot.ts +512 -0
  55. package/packages/core/src/extensions/web-fetch/fetch.ts +265 -0
  56. package/packages/core/src/extensions/web-fetch/image.ts +165 -0
  57. package/packages/core/src/extensions/web-fetch/index.ts +83 -0
  58. package/packages/core/src/extensions/web-fetch/render.ts +100 -0
  59. package/packages/core/src/extensions/web-fetch/schema.ts +46 -0
  60. package/packages/core/src/extensions/web-search/ExaMcpClient.ts +251 -0
  61. package/packages/core/src/extensions/web-search/SearchBreaker.ts +147 -0
  62. package/packages/core/src/extensions/web-search/SearchChain.ts +77 -0
  63. package/packages/core/src/extensions/web-search/index.ts +90 -0
  64. package/packages/core/src/extensions/web-search/providers/DuckDuckGoProvider.ts +195 -0
  65. package/packages/core/src/extensions/web-search/providers/ExaProvider.ts +54 -0
  66. package/packages/core/src/extensions/web-search/providers/FirecrawlProvider.ts +144 -0
  67. package/packages/core/src/extensions/web-search/providers/SearchProvider.ts +118 -0
  68. package/packages/core/src/extensions/web-search/render.ts +49 -0
  69. package/packages/core/src/extensions/web-search/schema.ts +29 -0
  70. package/packages/core/src/extensions/web-search/search.ts +23 -0
  71. package/packages/core/src/extensions/write/index.ts +58 -0
  72. package/packages/core/src/extensions/write/render.ts +16 -0
  73. package/packages/core/src/extensions/write/schema.ts +14 -0
  74. package/packages/core/src/extensions/write/write.ts +87 -0
  75. package/packages/core/src/picker/FilePickerSuggestionEngine.ts +14 -0
  76. package/packages/core/src/picker/InProcessFilePickerSuggestionEngine.ts +52 -0
  77. package/packages/core/src/picker/PickerItem.ts +6 -0
  78. package/packages/core/src/picker/PickerService.ts +110 -0
  79. package/packages/core/src/picker/RemoteFilePickerSuggestionEngine.ts +56 -0
  80. package/packages/core/src/picker/WorkerFilePickerSuggestionEngine.ts +268 -0
  81. package/packages/core/src/picker/catalog.ts +220 -0
  82. package/packages/core/src/picker/commandRanker.ts +17 -0
  83. package/packages/core/src/picker/filePickerWorker.ts +66 -0
  84. package/packages/core/src/picker/filePickerWorkerMessages.ts +39 -0
  85. package/packages/core/src/picker/ranker.ts +245 -0
  86. package/packages/core/src/picker/token.ts +70 -0
  87. package/packages/core/src/session/AgentRuntime.ts +85 -0
  88. package/packages/core/src/session/EventLog.ts +213 -0
  89. package/packages/core/src/session/MessageText.ts +21 -0
  90. package/packages/core/src/session/ReadCursors.ts +94 -0
  91. package/packages/core/src/session/SessionCache.ts +68 -0
  92. package/packages/core/src/session/SessionHost.ts +876 -0
  93. package/packages/core/src/session/SessionLease.ts +279 -0
  94. package/packages/core/src/session/SessionRegistry.ts +169 -0
  95. package/packages/core/src/session/WriteMark.ts +37 -0
  96. package/packages/core/src/shared/Cli.ts +25 -0
  97. package/packages/core/src/shared/DaemonUnit.ts +13 -0
  98. package/packages/core/src/shared/DiffLines.ts +304 -0
  99. package/packages/core/src/shared/DiffRenderer.ts +283 -0
  100. package/packages/core/src/shared/DiffView.ts +29 -0
  101. package/packages/core/src/shared/Directories.ts +61 -0
  102. package/packages/core/src/shared/EditMatcher.ts +767 -0
  103. package/packages/core/src/shared/Errors.ts +9 -0
  104. package/packages/core/src/shared/ExtensionToggles.ts +130 -0
  105. package/packages/core/src/shared/FileEnumerator.ts +361 -0
  106. package/packages/core/src/shared/FileScanner.ts +57 -0
  107. package/packages/core/src/shared/FileWatch.ts +107 -0
  108. package/packages/core/src/shared/Format.ts +74 -0
  109. package/packages/core/src/shared/Fs.ts +91 -0
  110. package/packages/core/src/shared/FsErrors.ts +67 -0
  111. package/packages/core/src/shared/FuzzyMatcher.ts +71 -0
  112. package/packages/core/src/shared/Git.ts +89 -0
  113. package/packages/core/src/shared/GlobExclusions.ts +13 -0
  114. package/packages/core/src/shared/Http.ts +14 -0
  115. package/packages/core/src/shared/ImageMime.ts +36 -0
  116. package/packages/core/src/shared/Images.ts +357 -0
  117. package/packages/core/src/shared/Json.ts +19 -0
  118. package/packages/core/src/shared/Languages.ts +95 -0
  119. package/packages/core/src/shared/Levenshtein.ts +30 -0
  120. package/packages/core/src/shared/Lines.ts +79 -0
  121. package/packages/core/src/shared/McpClient.ts +444 -0
  122. package/packages/core/src/shared/OutputBudget.ts +84 -0
  123. package/packages/core/src/shared/PatchSummary.ts +40 -0
  124. package/packages/core/src/shared/Paths.ts +80 -0
  125. package/packages/core/src/shared/PimSettings.ts +128 -0
  126. package/packages/core/src/shared/PimVersion.ts +93 -0
  127. package/packages/core/src/shared/Pool.ts +26 -0
  128. package/packages/core/src/shared/Proc.ts +30 -0
  129. package/packages/core/src/shared/RateLimiter.ts +50 -0
  130. package/packages/core/src/shared/Renderer.ts +371 -0
  131. package/packages/core/src/shared/SafePath.ts +17 -0
  132. package/packages/core/src/shared/SearchRender.ts +82 -0
  133. package/packages/core/src/shared/SendFile.ts +23 -0
  134. package/packages/core/src/shared/SpillCache.ts +78 -0
  135. package/packages/core/src/shared/SubagentLogs.ts +102 -0
  136. package/packages/core/src/shared/Supervisor.ts +460 -0
  137. package/packages/core/src/shared/Sweeper.ts +32 -0
  138. package/packages/core/src/shared/Tools.ts +801 -0
  139. package/packages/core/src/shared/Updater.ts +193 -0
  140. package/packages/core/src/view/AnsiPainter.ts +227 -0
  141. package/packages/core/src/view/BodyRenderer.ts +102 -0
  142. package/packages/core/src/view/DiffBlocks.ts +52 -0
  143. package/packages/core/src/view/DiffLayout.ts +69 -0
  144. package/packages/core/src/view/MarkdownPainter.ts +314 -0
  145. package/packages/core/src/view/Painting.ts +182 -0
  146. package/packages/core/src/view/ViewBlock.ts +124 -0
  147. package/packages/daemon/src/Daemon.ts +63 -0
  148. package/packages/daemon/src/DaemonInstall.ts +115 -0
  149. package/packages/daemon/src/Surfaces.ts +66 -0
  150. package/packages/daemon/src/TelegramSurface.ts +30 -0
  151. package/packages/daemon/src/WebSurface.ts +57 -0
  152. package/packages/daemon/src/index.ts +37 -0
  153. package/packages/protocol/src/Command.ts +92 -0
  154. package/packages/protocol/src/ImageRoute.ts +23 -0
  155. package/packages/protocol/src/Protocol.ts +6 -0
  156. package/packages/protocol/src/ServerEvent.ts +251 -0
  157. package/packages/server/src/AttachmentEndpoint.ts +184 -0
  158. package/packages/server/src/ClientConnection.ts +265 -0
  159. package/packages/server/src/ImageEndpoint.ts +36 -0
  160. package/packages/server/src/ProbeClient.ts +312 -0
  161. package/packages/server/src/Reloader.ts +93 -0
  162. package/packages/server/src/SendFileTool.ts +88 -0
  163. package/packages/server/src/SessionCatalogue.ts +202 -0
  164. package/packages/server/src/SessionProjection.ts +194 -0
  165. package/packages/server/src/SessionStream.ts +601 -0
  166. package/packages/server/src/StaticClient.ts +95 -0
  167. package/packages/server/src/WebOptions.ts +72 -0
  168. package/packages/server/src/WsGateway.ts +563 -0
  169. package/packages/server/src/probe.ts +146 -0
  170. package/packages/telegram/src/Bot.ts +229 -0
  171. package/packages/telegram/src/Commands.ts +768 -0
  172. package/packages/telegram/src/Config.ts +148 -0
  173. package/packages/telegram/src/Markdown.ts +235 -0
  174. package/packages/telegram/src/Message.ts +152 -0
  175. package/packages/telegram/src/Renderer.ts +456 -0
  176. package/packages/telegram/src/SendFileSchema.ts +18 -0
  177. package/packages/telegram/src/SendFileTool.ts +65 -0
  178. package/packages/telegram/src/Session.ts +252 -0
  179. package/packages/telegram/src/SessionRegistry.ts +126 -0
  180. package/packages/telegram/src/TaskScheduler.ts +296 -0
  181. package/packages/telegram/src/TaskSchema.ts +88 -0
  182. package/packages/telegram/src/TaskStore.ts +67 -0
  183. package/packages/telegram/src/TaskTool.ts +208 -0
  184. package/packages/telegram/src/TelegramHtml.ts +288 -0
  185. package/packages/telegram/src/TypingIndicator.ts +43 -0
  186. package/packages/telegram/src/UpdateConfirm.ts +61 -0
  187. package/packages/tui/src/extensions/_init/index.ts +144 -0
  188. package/packages/tui/src/extensions/command-picker/index.ts +43 -0
  189. package/packages/tui/src/extensions/file-picker/index.ts +156 -0
  190. package/packages/tui/src/extensions/footer/index.ts +117 -0
  191. package/packages/tui/src/extensions/footer/powerline.ts +87 -0
  192. package/packages/tui/src/extensions/footer/segments.ts +202 -0
  193. package/packages/tui/src/extensions/pim/index.ts +89 -0
  194. package/packages/tui/src/extensions/session-lease/index.ts +248 -0
  195. package/packages/tui/src/extensions/tps/index.ts +118 -0
  196. package/packages/tui/src/extensions/working-indicator/index.ts +75 -0
  197. package/packages/tui/src/extensions/wrapProvider.ts +18 -0
  198. package/packages/tui/src/themes/pim-dark.json +84 -0
  199. package/packages/tui/src/themes/pim-light.json +84 -0
  200. package/packages/tui/src/themes/themeCliArgs.ts +20 -0
  201. package/packages/web/dist/client/apple-touch-icon.png +0 -0
  202. package/packages/web/dist/client/assets/bash-LmRjUKlR.js +1 -0
  203. package/packages/web/dist/client/assets/c-DBVbJFpC.js +1 -0
  204. package/packages/web/dist/client/assets/clojure-CR7dvLJ4.js +1 -0
  205. package/packages/web/dist/client/assets/cmake-CgP6gB4W.js +1 -0
  206. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  207. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  208. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  209. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  210. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  211. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  212. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  213. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  214. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  215. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  216. package/packages/web/dist/client/assets/core-CGdQx9la.js +3 -0
  217. package/packages/web/dist/client/assets/cpp-N2d8M7Nc.js +1 -0
  218. package/packages/web/dist/client/assets/csharp-yYt0Ikks.js +1 -0
  219. package/packages/web/dist/client/assets/css-D8M8GYaw.js +1 -0
  220. package/packages/web/dist/client/assets/diff-BhdZizse.js +1 -0
  221. package/packages/web/dist/client/assets/dockerfile-C0zufGsU.js +1 -0
  222. package/packages/web/dist/client/assets/elixir-DXIHlGsR.js +1 -0
  223. package/packages/web/dist/client/assets/erlang-BjYI8dhz.js +1 -0
  224. package/packages/web/dist/client/assets/go-CYstVVJV.js +1 -0
  225. package/packages/web/dist/client/assets/graphql-BHHhacaR.js +1 -0
  226. package/packages/web/dist/client/assets/haskell-DUkhXIda.js +1 -0
  227. package/packages/web/dist/client/assets/index-BMNwleWO.js +40 -0
  228. package/packages/web/dist/client/assets/index-CqjB2RPr.css +1 -0
  229. package/packages/web/dist/client/assets/ini-B1CroMsf.js +1 -0
  230. package/packages/web/dist/client/assets/java-Dde-Wnb4.js +1 -0
  231. package/packages/web/dist/client/assets/javascript-8AmHuH5O.js +1 -0
  232. package/packages/web/dist/client/assets/json-PmyKWHmm.js +1 -0
  233. package/packages/web/dist/client/assets/kotlin-DgSK8bu-.js +2 -0
  234. package/packages/web/dist/client/assets/less-Bp0mgmpG.js +1 -0
  235. package/packages/web/dist/client/assets/lua-1dXYaL-H.js +1 -0
  236. package/packages/web/dist/client/assets/makefile-BX-o3EIz.js +1 -0
  237. package/packages/web/dist/client/assets/markdown-D6FrnlqJ.js +1 -0
  238. package/packages/web/dist/client/assets/ocaml-B-P1Uyq8.js +1 -0
  239. package/packages/web/dist/client/assets/perl-CvqMZYuf.js +1 -0
  240. package/packages/web/dist/client/assets/php-DuZl5e-L.js +2 -0
  241. package/packages/web/dist/client/assets/powershell-BUe16zE4.js +1 -0
  242. package/packages/web/dist/client/assets/protobuf-DGOW1qT8.js +1 -0
  243. package/packages/web/dist/client/assets/python-gHmu6VPG.js +1 -0
  244. package/packages/web/dist/client/assets/r-DNWPCnjT.js +1 -0
  245. package/packages/web/dist/client/assets/ruby-BGpZ3p4_.js +1 -0
  246. package/packages/web/dist/client/assets/rust-BIKPVoi6.js +1 -0
  247. package/packages/web/dist/client/assets/scala-C9lP4FWY.js +1 -0
  248. package/packages/web/dist/client/assets/scss-gOyOKiXd.js +1 -0
  249. package/packages/web/dist/client/assets/sql-DWRKyvJ-.js +1 -0
  250. package/packages/web/dist/client/assets/swift-BTn9__hL.js +1 -0
  251. package/packages/web/dist/client/assets/typescript-CxkPLmSc.js +1 -0
  252. package/packages/web/dist/client/assets/vim-Dhhcn_CA.js +1 -0
  253. package/packages/web/dist/client/assets/xml-COl5yFSC.js +1 -0
  254. package/packages/web/dist/client/assets/yaml-DjOhhVLG.js +1 -0
  255. package/packages/web/dist/client/favicon.svg +1 -0
  256. package/packages/web/dist/client/icon-192.png +0 -0
  257. package/packages/web/dist/client/icon-512.png +0 -0
  258. package/packages/web/dist/client/icon-maskable.png +0 -0
  259. package/packages/web/dist/client/index.html +24 -0
  260. package/packages/web/dist/client/manifest.webmanifest +21 -0
  261. package/packages/web/dist/client/wordmark.svg +1 -0
@@ -0,0 +1,72 @@
1
+ import { resolve } from "node:path";
2
+
3
+ import { Cli as Argv } from "#core/shared/Cli";
4
+ import { DEFAULT_HOSTNAME, DEFAULT_PORT } from "./WsGateway";
5
+
6
+ export type WebOptions = {
7
+ readonly port: string;
8
+ readonly hostname: string;
9
+ readonly cwd: string;
10
+ readonly clientDir: string | undefined;
11
+ };
12
+
13
+ /**
14
+ * `--web-cwd` wins over `--cwd`, which the Telegram surface reads out of the
15
+ * same argv as its own default directory. Only the frozen unit uses it.
16
+ */
17
+ function parse(args: ReadonlyArray<string>): WebOptions {
18
+ // An explicit `--port` always wins over `PORT`.
19
+ let port = process.env["PORT"] ?? String(DEFAULT_PORT);
20
+ let hostname = DEFAULT_HOSTNAME;
21
+ let cwd = process.cwd();
22
+ let webCwd: string | undefined;
23
+ let clientDir: string | undefined;
24
+
25
+ Argv.scan(args, (key, take) => {
26
+ switch (key) {
27
+ case "--port":
28
+ port = take() ?? port;
29
+ break;
30
+ case "--hostname":
31
+ hostname = take() ?? hostname;
32
+ break;
33
+ case "--cwd":
34
+ cwd = take() ?? cwd;
35
+ break;
36
+ case "--web-cwd":
37
+ webCwd = take() ?? webCwd;
38
+ break;
39
+ case "--client-dir":
40
+ clientDir = take() ?? clientDir;
41
+ break;
42
+ default:
43
+ break;
44
+ }
45
+ });
46
+ return { port, hostname, cwd: webCwd ?? cwd, clientDir };
47
+ }
48
+
49
+ /** The argv a supervisor has to start the daemon with to serve these same options. */
50
+ function freeze(options: WebOptions): ReadonlyArray<string> {
51
+ return [
52
+ "--port",
53
+ options.port,
54
+ "--hostname",
55
+ options.hostname,
56
+ "--web-cwd",
57
+ resolve(options.cwd),
58
+ ...(options.clientDir === undefined
59
+ ? []
60
+ : ["--client-dir", resolve(options.clientDir)]),
61
+ ];
62
+ }
63
+
64
+ function port(options: WebOptions): number {
65
+ const value = Number(options.port);
66
+ if (!Number.isInteger(value) || value < 0 || value > 65535) {
67
+ throw new Error(`--port must be a port number, got "${options.port}"`);
68
+ }
69
+ return value;
70
+ }
71
+
72
+ export const WebOptions = { parse, freeze, port };
@@ -0,0 +1,563 @@
1
+ import type { Server, ServerWebSocket } from "bun";
2
+
3
+ import type { ImageContent } from "@earendil-works/pi-ai";
4
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
5
+
6
+ import { Attachments } from "#core/attachments/Attachments";
7
+ import type { PickerItem } from "#core/picker/PickerItem";
8
+ import { Directories } from "#core/shared/Directories";
9
+ import type { DirectoryListing } from "#core/shared/Directories";
10
+ import { ReadCursors } from "#core/session/ReadCursors";
11
+ import type { SessionHost } from "#core/session/SessionHost";
12
+ import type { SessionRegistry } from "#core/session/SessionRegistry";
13
+ import { PimVersion } from "#core/shared/PimVersion";
14
+ import { SubagentLogs } from "#core/shared/SubagentLogs";
15
+ import type { UpdateOutcome } from "#core/shared/Updater";
16
+ import type { Command } from "#protocol/Command";
17
+ import { CLOSE_PROTOCOL_MISMATCH, PROTOCOL_VERSION } from "#protocol/Protocol";
18
+ import type {
19
+ ModelView,
20
+ ServerEvent,
21
+ SessionSummaryView,
22
+ } from "#protocol/ServerEvent";
23
+ import { ClientConnection } from "./ClientConnection";
24
+ import { Reloader } from "./Reloader";
25
+ import { SessionCatalogue } from "./SessionCatalogue";
26
+ import { SessionProjection } from "./SessionProjection";
27
+ import { SessionStream } from "./SessionStream";
28
+ import { StaticClient } from "./StaticClient";
29
+ import { AttachmentEndpoint } from "./AttachmentEndpoint";
30
+ import { ImageEndpoint } from "./ImageEndpoint";
31
+
32
+ export type WsGatewayDeps = {
33
+ readonly registry: SessionRegistry;
34
+ /** Loopback by default and never `0.0.0.0`: full host access, no auth. */
35
+ readonly hostname?: string;
36
+ /** 0 asks the OS for a free port; read it back from `port`. */
37
+ readonly port?: number;
38
+ /** Where uploaded bytes are kept; defaults to `~/.pim/attachments`. */
39
+ readonly attachmentsRoot?: string;
40
+ /** Where `read` spilled the pictures it showed the model; defaults to `~/.pim/cache`. */
41
+ readonly imagesRoot?: string;
42
+ /** Where the read cursors live; defaults to `~/.pim/read.json`. */
43
+ readonly readCursorsPath?: string;
44
+ /** The built web client; defaults to the bundle shipped beside this package. */
45
+ readonly clientDir?: string;
46
+ /** Runs the update a `reload` asks for, reporting each step as it starts. */
47
+ readonly update?: (onStep: (label: string) => void) => Promise<UpdateOutcome>;
48
+ /** Ends this process so the supervisor replaces it with the updated code. */
49
+ readonly shutdown?: () => Promise<void>;
50
+ };
51
+
52
+ type Outcome = {
53
+ readonly error?: string;
54
+ readonly items?: readonly PickerItem[];
55
+ readonly sessions?: readonly SessionSummaryView[];
56
+ readonly models?: readonly ModelView[];
57
+ readonly thinkingLevels?: readonly string[];
58
+ readonly directory?: DirectoryListing;
59
+ readonly restored?: readonly string[];
60
+ readonly after?: () => void;
61
+ };
62
+
63
+ type StreamTarget = {
64
+ readonly sessionId?: string;
65
+ readonly cwd?: string;
66
+ readonly like?: SessionHost;
67
+ };
68
+
69
+ export const DEFAULT_PORT = 4319;
70
+
71
+ export const DEFAULT_HOSTNAME = "127.0.0.1";
72
+
73
+ // Bun 1.3.14 never frees the slot of a server-closed socket, so an unbounded `Server.stop(true)` hangs.
74
+ const STOP_GRACE_MS = 250;
75
+
76
+ /** The transport half of pim-server: a WebSocket endpoint over the session runtime in `core`. */
77
+ export class WsGateway {
78
+ private readonly registry: SessionRegistry;
79
+ private readonly hostname: string;
80
+ private readonly requestedPort: number;
81
+ private readonly uploads: AttachmentEndpoint;
82
+ private readonly images: ImageEndpoint;
83
+ private readonly catalogue: SessionCatalogue;
84
+ private readonly client: StaticClient;
85
+ private readonly streams = new Map<string, SessionStream>();
86
+ private readonly opening = new Map<string, Promise<SessionStream>>();
87
+ private readonly connections = new Map<
88
+ ServerWebSocket<undefined>,
89
+ ClientConnection
90
+ >();
91
+ private readonly reloader: Reloader;
92
+ private versionsRead: Promise<readonly [string, string]> | undefined;
93
+ private server: Server<undefined> | undefined;
94
+
95
+ public constructor(deps: WsGatewayDeps) {
96
+ this.registry = deps.registry;
97
+ this.hostname = deps.hostname ?? DEFAULT_HOSTNAME;
98
+ this.requestedPort = deps.port ?? DEFAULT_PORT;
99
+ this.uploads = new AttachmentEndpoint(
100
+ deps.attachmentsRoot === undefined ? {} : { root: deps.attachmentsRoot }
101
+ );
102
+ this.images = new ImageEndpoint(
103
+ deps.imagesRoot === undefined ? {} : { root: deps.imagesRoot }
104
+ );
105
+ this.catalogue = new SessionCatalogue({
106
+ registry: deps.registry,
107
+ cursors: new ReadCursors(deps.readCursorsPath),
108
+ liveStatus: (sessionId) => this.streams.get(sessionId)?.host.status,
109
+ liveSessionIds: () => this.streams.keys(),
110
+ isBeingRead: (sessionId) => this.isBeingRead(sessionId),
111
+ announce: (event) => {
112
+ this.broadcast(event);
113
+ },
114
+ });
115
+ this.client = new StaticClient(deps.clientDir);
116
+ this.reloader = new Reloader({
117
+ announce: (event) => {
118
+ this.broadcast(event);
119
+ },
120
+ update: deps.update,
121
+ shutdown: deps.shutdown,
122
+ });
123
+ }
124
+
125
+ public get port(): number {
126
+ const port = this.server?.port;
127
+ if (port === undefined) {
128
+ throw new Error("WsGateway is not listening");
129
+ }
130
+ return port;
131
+ }
132
+
133
+ public get url(): string {
134
+ return `ws://${this.hostname}:${this.port}`;
135
+ }
136
+
137
+ public start(): void {
138
+ if (this.server) {
139
+ return;
140
+ }
141
+ this.server = Bun.serve({
142
+ hostname: this.hostname,
143
+ port: this.requestedPort,
144
+ idleTimeout: 0,
145
+ fetch: (req, server) => {
146
+ const { pathname } = new URL(req.url);
147
+ if (pathname === "/health") {
148
+ return Response.json({ ok: true, protocolVersion: PROTOCOL_VERSION });
149
+ }
150
+ if (AttachmentEndpoint.owns(pathname)) {
151
+ return this.uploads.handle(req);
152
+ }
153
+ if (ImageEndpoint.owns(pathname)) {
154
+ return this.images.handle(req);
155
+ }
156
+ // Try the upgrade first: the socket must answer on every path.
157
+ return server.upgrade(req) ? undefined : this.client.handle(req);
158
+ },
159
+ websocket: {
160
+ backpressureLimit: 8 << 20,
161
+ closeOnBackpressureLimit: false,
162
+ open: (ws) => {
163
+ this.connections.set(ws, new ClientConnection(ws));
164
+ this.syncWatches();
165
+ },
166
+ message: (ws, raw) => {
167
+ void this.onMessage(ws, raw);
168
+ },
169
+ drain: (ws) => {
170
+ this.connections.get(ws)?.onDrain();
171
+ },
172
+ close: (ws) => {
173
+ this.connections.get(ws)?.close();
174
+ this.connections.delete(ws);
175
+ this.syncWatches();
176
+ },
177
+ },
178
+ });
179
+ }
180
+
181
+ public async stop(): Promise<void> {
182
+ for (const stream of this.streams.values()) {
183
+ stream.dispose();
184
+ }
185
+ this.streams.clear();
186
+ this.opening.clear();
187
+ this.catalogue.watch(false);
188
+ this.catalogue.clear();
189
+ await this.catalogue.flush();
190
+ for (const connection of this.connections.values()) {
191
+ connection.close();
192
+ }
193
+ this.connections.clear();
194
+ const server = this.server;
195
+ this.server = undefined;
196
+ if (server) {
197
+ await Promise.race([server.stop(true), Bun.sleep(STOP_GRACE_MS)]);
198
+ }
199
+ }
200
+
201
+ private async onMessage(
202
+ ws: ServerWebSocket<undefined>,
203
+ raw: string | Buffer
204
+ ): Promise<void> {
205
+ const connection = this.connections.get(ws);
206
+ if (!connection) {
207
+ return;
208
+ }
209
+ let command: Command;
210
+ try {
211
+ command = JSON.parse(String(raw)) as Command;
212
+ } catch {
213
+ connection.send({ type: "error", message: "malformed frame" });
214
+ return;
215
+ }
216
+ if (typeof command?.id !== "string" || typeof command?.type !== "string") {
217
+ connection.send({ type: "error", message: "frame is not a command" });
218
+ return;
219
+ }
220
+ if (
221
+ command.type === "attach" &&
222
+ command.protocolVersion !== PROTOCOL_VERSION
223
+ ) {
224
+ connection.send({
225
+ type: "response",
226
+ id: command.id,
227
+ success: false,
228
+ error: `unsupported protocol version ${String(command.protocolVersion)}; this server speaks ${PROTOCOL_VERSION}`,
229
+ });
230
+ ws.close(CLOSE_PROTOCOL_MISMATCH, "protocol version mismatch");
231
+ return;
232
+ }
233
+ try {
234
+ const { error, after, ...answer } = await this.dispatch(
235
+ connection,
236
+ command
237
+ );
238
+ connection.send({
239
+ type: "response",
240
+ id: command.id,
241
+ success: error === undefined,
242
+ ...(error === undefined ? {} : { error }),
243
+ ...answer,
244
+ });
245
+ after?.();
246
+ } catch (err) {
247
+ connection.send({
248
+ type: "response",
249
+ id: command.id,
250
+ success: false,
251
+ error: (err as Error).message ?? String(err),
252
+ });
253
+ }
254
+ }
255
+
256
+ private async dispatch(
257
+ connection: ClientConnection,
258
+ command: Command
259
+ ): Promise<Outcome> {
260
+ switch (command.type) {
261
+ case "attach":
262
+ return await this.attach(connection, command);
263
+ case "list_sessions":
264
+ return { sessions: await this.catalogue.list(command) };
265
+ case "list_models":
266
+ return {
267
+ models: this.registry.models(),
268
+ thinkingLevels:
269
+ this.streamFor(connection)?.host.supportedThinkingLevels ?? [],
270
+ };
271
+ case "list_dirs":
272
+ return { directory: await Directories.list(command.path) };
273
+ case "unwatch_subagent":
274
+ connection.unwatchSubagent(command.callId);
275
+ return {};
276
+ case "reload":
277
+ return this.reload(command.force === true);
278
+ case "user_message":
279
+ this.promptWithAttachments(this.requireStream(connection), command);
280
+ return {};
281
+ case "cancel": {
282
+ const { cancelled, restored } =
283
+ await this.requireStream(connection).host.cancel();
284
+ return cancelled ? { restored } : { error: "nothing to cancel" };
285
+ }
286
+ case "dequeue":
287
+ return { restored: this.requireStream(connection).host.takeBack() };
288
+ case "set_cwd": {
289
+ const stream = this.requireStream(connection);
290
+ const result = await stream.host.setCwd(command.value);
291
+ stream.push(stream.sessionState());
292
+ if (!result.ok) {
293
+ return { error: result.error };
294
+ }
295
+ stream.invalidatePickers("all");
296
+ return {};
297
+ }
298
+ case "set_model": {
299
+ const stream = this.requireStream(connection);
300
+ const result = await stream.host.setModel(command.value);
301
+ stream.push(stream.sessionState());
302
+ return result.ok
303
+ ? {}
304
+ : {
305
+ error: `${result.kind} model "${command.value}"; candidates: ${result.candidates.join(", ")}`,
306
+ };
307
+ }
308
+ case "set_thinking": {
309
+ const stream = this.requireStream(connection);
310
+ await stream.host.setThinkingLevel(command.value as ThinkingLevel);
311
+ stream.push(stream.sessionState());
312
+ return {};
313
+ }
314
+ case "pick_files":
315
+ return {
316
+ items: await this.requireStream(connection).picker.files(
317
+ command.query,
318
+ command.limit
319
+ ),
320
+ };
321
+ case "pick_commands":
322
+ return {
323
+ items: this.requireStream(connection).picker.commands(
324
+ command.query,
325
+ command.limit
326
+ ),
327
+ };
328
+ case "watch_subagent":
329
+ return await this.watchSubagent(
330
+ connection,
331
+ this.requireStream(connection),
332
+ command
333
+ );
334
+ default:
335
+ this.requireStream(connection);
336
+ return { error: `unknown command: ${(command as Command).type}` };
337
+ }
338
+ }
339
+
340
+ private streamFor(connection: ClientConnection): SessionStream | undefined {
341
+ const sessionId = connection.sessionId;
342
+ return sessionId ? this.streams.get(sessionId) : undefined;
343
+ }
344
+
345
+ private requireStream(connection: ClientConnection): SessionStream {
346
+ const stream = this.streamFor(connection);
347
+ if (!stream) {
348
+ throw new Error("not attached: send `attach` first");
349
+ }
350
+ return stream;
351
+ }
352
+
353
+ // Derive the log path from the parent session and call id: a client-sent path reads arbitrary JSONL off this machine.
354
+ private async watchSubagent(
355
+ connection: ClientConnection,
356
+ stream: SessionStream,
357
+ command: Command & { readonly type: "watch_subagent" }
358
+ ): Promise<Outcome> {
359
+ if (command.sessionId !== stream.sessionId) {
360
+ return {
361
+ error: `not attached to session ${command.sessionId}; a watch reads a child of the attached session only`,
362
+ };
363
+ }
364
+ const path = SubagentLogs.pathFor(stream.sessionId, command.callId);
365
+ if (path === null) {
366
+ return { error: `malformed call id: ${command.callId}` };
367
+ }
368
+ if (!(await Bun.file(path).exists())) {
369
+ return { error: `no subagent log for call ${command.callId}` };
370
+ }
371
+ await connection.watchSubagent(
372
+ command.callId,
373
+ new SessionProjection(path, () => stream.host.cwd),
374
+ command.fromSeq
375
+ );
376
+ return {};
377
+ }
378
+
379
+ private async attach(
380
+ connection: ClientConnection,
381
+ command: Command & { readonly type: "attach" }
382
+ ): Promise<Outcome> {
383
+ const like =
384
+ command.like === undefined
385
+ ? undefined
386
+ : this.streams.get(command.like)?.host;
387
+ const [stream, [pimVersion, piVersion]] = await Promise.all([
388
+ this.ensureStream({
389
+ ...(command.sessionId === undefined
390
+ ? {}
391
+ : { sessionId: command.sessionId }),
392
+ ...(command.cwd === undefined ? {} : { cwd: command.cwd }),
393
+ ...(like === undefined ? {} : { like }),
394
+ }),
395
+ this.versions(),
396
+ ]);
397
+ connection.send({
398
+ type: "attached",
399
+ protocolVersion: PROTOCOL_VERSION,
400
+ sessionId: stream.sessionId,
401
+ cwd: stream.host.cwd,
402
+ head: await stream.refresh(),
403
+ pimVersion,
404
+ piVersion,
405
+ });
406
+ await connection.attach(stream, command.fromSeq);
407
+ this.syncWatches();
408
+ await this.catalogue.markRead(stream.sessionId);
409
+ return {};
410
+ }
411
+
412
+ /** A watch costs a poll, so only sessions someone is reading get one — and the catalogue only while someone is connected. */
413
+ private syncWatches(): void {
414
+ for (const [sessionId, stream] of this.streams) {
415
+ stream.watchFiles(this.isBeingRead(sessionId));
416
+ }
417
+ this.catalogue.watch(this.connections.size > 0);
418
+ }
419
+
420
+ private reload(force: boolean): Outcome {
421
+ const busy = [...this.streams.values()]
422
+ .filter((stream) => stream.host.status !== "idle")
423
+ .map((stream) => stream.sessionId);
424
+ if (!force && busy.length > 0) {
425
+ const many = busy.length > 1;
426
+ return {
427
+ error: `${many ? "sessions" : "session"} ${busy.join(", ")} ${many ? "are" : "is"} mid-turn; reloading would kill ${many ? "those turns" : "that turn"}. Send \`reload\` with \`force\` to do it anyway`,
428
+ };
429
+ }
430
+ return {
431
+ after: () => {
432
+ void this.reloader.start();
433
+ },
434
+ };
435
+ }
436
+
437
+ private versions(): Promise<readonly [string, string]> {
438
+ this.versionsRead ??= Promise.all([PimVersion.current(), PimVersion.pi()]);
439
+ return this.versionsRead;
440
+ }
441
+
442
+ private isBeingRead(sessionId: string): boolean {
443
+ for (const connection of this.connections.values()) {
444
+ if (connection.sessionId === sessionId) {
445
+ return true;
446
+ }
447
+ }
448
+ return false;
449
+ }
450
+
451
+ private broadcast(event: ServerEvent): void {
452
+ for (const connection of this.connections.values()) {
453
+ connection.send(event);
454
+ }
455
+ }
456
+
457
+ private async ensureStream(target: StreamTarget): Promise<SessionStream> {
458
+ const sessionId = target.sessionId;
459
+ if (sessionId) {
460
+ const live = this.streams.get(sessionId);
461
+ if (live) {
462
+ return live;
463
+ }
464
+ const pending = this.opening.get(sessionId);
465
+ if (pending) {
466
+ return await pending;
467
+ }
468
+ }
469
+ const build = this.buildStream(target);
470
+ if (sessionId) {
471
+ this.opening.set(sessionId, build);
472
+ }
473
+ try {
474
+ return await build;
475
+ } finally {
476
+ if (sessionId) {
477
+ this.opening.delete(sessionId);
478
+ }
479
+ }
480
+ }
481
+
482
+ private async buildStream(target: StreamTarget): Promise<SessionStream> {
483
+ const sessionId = target.sessionId;
484
+ const host = sessionId
485
+ ? await this.registry.open(sessionId)
486
+ : await this.registry.create({
487
+ ...(target.cwd === undefined ? {} : { cwd: target.cwd }),
488
+ ...(target.like === undefined ? {} : { like: target.like }),
489
+ });
490
+ // Pi assigns the id and the session file only once an agent exists.
491
+ const agent = host.agentSession ?? (await host.ensureAgent());
492
+ const id = agent.sessionId;
493
+ const path = agent.sessionFile ?? host.settings.sessionPath;
494
+ if (!path) {
495
+ throw new Error(`session ${sessionId ?? "(new)"} has no agent`);
496
+ }
497
+ const existing = this.streams.get(id);
498
+ if (existing) {
499
+ return existing;
500
+ }
501
+ const stream = new SessionStream(id, host, path);
502
+ stream.start();
503
+ this.catalogue.track(id, host.status);
504
+ stream.subscribe((event) => {
505
+ if (event.type === "session_state") {
506
+ this.catalogue.onStatus(id, event.status);
507
+ }
508
+ });
509
+ this.streams.set(id, stream);
510
+ return stream;
511
+ }
512
+
513
+ private promptWithAttachments(
514
+ stream: SessionStream,
515
+ command: Command & { readonly type: "user_message" }
516
+ ): void {
517
+ const taken = this.uploads.take(
518
+ stream.sessionId,
519
+ (command.attachments ?? []).map((ref) => ref.id)
520
+ );
521
+ const { lines, images } = Attachments.render(taken);
522
+ const text = [command.text, ...lines].filter(Boolean).join("\n\n").trim();
523
+ this.prompt(stream, text, images);
524
+ }
525
+
526
+ // Merge into the queued message: pi holds at most one, so a second queued send would be unreachable.
527
+ private prompt(
528
+ stream: SessionStream,
529
+ text: string,
530
+ images: readonly ImageContent[] = []
531
+ ): void {
532
+ const attached = images.length === 0 ? {} : { images: [...images] };
533
+ const host = stream.host;
534
+ const agent = host.agentSession;
535
+ const failed = (err: unknown, what: string): void => {
536
+ console.error(`[gateway] ${what} failed:`, err);
537
+ stream.push({
538
+ type: "error",
539
+ message: (err as Error).message || String(err),
540
+ });
541
+ };
542
+ if (agent && host.isStreaming) {
543
+ const queued = [...host.takeBack(), text].join("\n\n");
544
+ void agent
545
+ .prompt(queued, {
546
+ streamingBehavior: "steer",
547
+ source: "rpc",
548
+ ...attached,
549
+ })
550
+ .catch((err: unknown) => {
551
+ failed(err, "steer");
552
+ });
553
+ return;
554
+ }
555
+ void host
556
+ .run(async (session) => {
557
+ await session.prompt(text, { source: "rpc", ...attached });
558
+ })
559
+ .catch((err: unknown) => {
560
+ failed(err, "turn");
561
+ });
562
+ }
563
+ }