pim-agent 0.0.1 → 0.10.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 (275) 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 +54 -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 +177 -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 +36 -0
  47. package/packages/core/src/extensions/system-prompt/prompt.ts +232 -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 +132 -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 +77 -0
  87. package/packages/core/src/session/AgentRuntime.ts +85 -0
  88. package/packages/core/src/session/EventLog.ts +156 -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/SearchIndex.ts +752 -0
  92. package/packages/core/src/session/SearchTokens.ts +97 -0
  93. package/packages/core/src/session/SessionCache.ts +68 -0
  94. package/packages/core/src/session/SessionDigest.ts +161 -0
  95. package/packages/core/src/session/SessionHost.ts +892 -0
  96. package/packages/core/src/session/SessionLease.ts +279 -0
  97. package/packages/core/src/session/SessionMeta.ts +267 -0
  98. package/packages/core/src/session/SessionName.ts +18 -0
  99. package/packages/core/src/session/SessionRegistry.ts +244 -0
  100. package/packages/core/src/session/WriteMark.ts +51 -0
  101. package/packages/core/src/shared/Cli.ts +25 -0
  102. package/packages/core/src/shared/DaemonUnit.ts +13 -0
  103. package/packages/core/src/shared/DiffLines.ts +304 -0
  104. package/packages/core/src/shared/DiffPatch.ts +70 -0
  105. package/packages/core/src/shared/DiffRenderer.ts +283 -0
  106. package/packages/core/src/shared/DiffView.ts +29 -0
  107. package/packages/core/src/shared/Directories.ts +61 -0
  108. package/packages/core/src/shared/EditMatcher.ts +767 -0
  109. package/packages/core/src/shared/Errors.ts +9 -0
  110. package/packages/core/src/shared/ExtensionToggles.ts +130 -0
  111. package/packages/core/src/shared/FileEnumerator.ts +361 -0
  112. package/packages/core/src/shared/FileScanner.ts +57 -0
  113. package/packages/core/src/shared/FileWatch.ts +121 -0
  114. package/packages/core/src/shared/Format.ts +74 -0
  115. package/packages/core/src/shared/Fs.ts +91 -0
  116. package/packages/core/src/shared/FsErrors.ts +67 -0
  117. package/packages/core/src/shared/FuzzyMatcher.ts +71 -0
  118. package/packages/core/src/shared/Git.ts +530 -0
  119. package/packages/core/src/shared/GitMonitor.ts +221 -0
  120. package/packages/core/src/shared/GlobExclusions.ts +13 -0
  121. package/packages/core/src/shared/Http.ts +14 -0
  122. package/packages/core/src/shared/ImageMime.ts +36 -0
  123. package/packages/core/src/shared/Images.ts +357 -0
  124. package/packages/core/src/shared/Json.ts +19 -0
  125. package/packages/core/src/shared/Languages.ts +95 -0
  126. package/packages/core/src/shared/Levenshtein.ts +83 -0
  127. package/packages/core/src/shared/Lines.ts +79 -0
  128. package/packages/core/src/shared/McpClient.ts +444 -0
  129. package/packages/core/src/shared/OutputBudget.ts +84 -0
  130. package/packages/core/src/shared/PatchSummary.ts +40 -0
  131. package/packages/core/src/shared/Paths.ts +80 -0
  132. package/packages/core/src/shared/PimSettings.ts +128 -0
  133. package/packages/core/src/shared/PimVersion.ts +93 -0
  134. package/packages/core/src/shared/Pool.ts +26 -0
  135. package/packages/core/src/shared/Proc.ts +50 -0
  136. package/packages/core/src/shared/RateLimiter.ts +50 -0
  137. package/packages/core/src/shared/Renderer.ts +371 -0
  138. package/packages/core/src/shared/RepoDiff.ts +741 -0
  139. package/packages/core/src/shared/SafePath.ts +17 -0
  140. package/packages/core/src/shared/SearchRender.ts +82 -0
  141. package/packages/core/src/shared/SendFile.ts +23 -0
  142. package/packages/core/src/shared/SpillCache.ts +78 -0
  143. package/packages/core/src/shared/SubagentLogs.ts +102 -0
  144. package/packages/core/src/shared/Supervisor.ts +460 -0
  145. package/packages/core/src/shared/Surface.ts +2 -0
  146. package/packages/core/src/shared/Sweeper.ts +32 -0
  147. package/packages/core/src/shared/Tools.ts +801 -0
  148. package/packages/core/src/shared/Updater.ts +209 -0
  149. package/packages/core/src/view/AnsiPainter.ts +227 -0
  150. package/packages/core/src/view/BodyRenderer.ts +102 -0
  151. package/packages/core/src/view/DiffBlocks.ts +52 -0
  152. package/packages/core/src/view/DiffExpand.ts +214 -0
  153. package/packages/core/src/view/DiffLayout.ts +69 -0
  154. package/packages/core/src/view/DiffPairs.ts +47 -0
  155. package/packages/core/src/view/MarkdownPainter.ts +314 -0
  156. package/packages/core/src/view/MovePath.ts +70 -0
  157. package/packages/core/src/view/Painting.ts +182 -0
  158. package/packages/core/src/view/ViewBlock.ts +124 -0
  159. package/packages/daemon/src/Daemon.ts +63 -0
  160. package/packages/daemon/src/DaemonInstall.ts +115 -0
  161. package/packages/daemon/src/Surfaces.ts +66 -0
  162. package/packages/daemon/src/TelegramSurface.ts +30 -0
  163. package/packages/daemon/src/WebSurface.ts +56 -0
  164. package/packages/daemon/src/index.ts +37 -0
  165. package/packages/protocol/src/Command.ts +219 -0
  166. package/packages/protocol/src/Diff.ts +15 -0
  167. package/packages/protocol/src/ImageRoute.ts +23 -0
  168. package/packages/protocol/src/ServerEvent.ts +406 -0
  169. package/packages/server/src/AttachmentEndpoint.ts +184 -0
  170. package/packages/server/src/ClientConnection.ts +277 -0
  171. package/packages/server/src/ImageEndpoint.ts +36 -0
  172. package/packages/server/src/ProbeClient.ts +374 -0
  173. package/packages/server/src/Reloader.ts +93 -0
  174. package/packages/server/src/SendFileTool.ts +88 -0
  175. package/packages/server/src/SessionCatalogue.ts +456 -0
  176. package/packages/server/src/SessionProjection.ts +194 -0
  177. package/packages/server/src/SessionStream.ts +627 -0
  178. package/packages/server/src/StaticClient.ts +178 -0
  179. package/packages/server/src/WebOptions.ts +72 -0
  180. package/packages/server/src/WsGateway.ts +798 -0
  181. package/packages/server/src/probe.ts +200 -0
  182. package/packages/telegram/src/Bot.ts +229 -0
  183. package/packages/telegram/src/Commands.ts +768 -0
  184. package/packages/telegram/src/Config.ts +148 -0
  185. package/packages/telegram/src/Markdown.ts +235 -0
  186. package/packages/telegram/src/Message.ts +152 -0
  187. package/packages/telegram/src/Renderer.ts +456 -0
  188. package/packages/telegram/src/SendFileSchema.ts +18 -0
  189. package/packages/telegram/src/SendFileTool.ts +65 -0
  190. package/packages/telegram/src/Session.ts +253 -0
  191. package/packages/telegram/src/SessionRegistry.ts +126 -0
  192. package/packages/telegram/src/TaskScheduler.ts +296 -0
  193. package/packages/telegram/src/TaskSchema.ts +88 -0
  194. package/packages/telegram/src/TaskStore.ts +67 -0
  195. package/packages/telegram/src/TaskTool.ts +208 -0
  196. package/packages/telegram/src/TelegramHtml.ts +288 -0
  197. package/packages/telegram/src/TypingIndicator.ts +43 -0
  198. package/packages/telegram/src/UpdateConfirm.ts +61 -0
  199. package/packages/tui/src/extensions/_init/index.ts +144 -0
  200. package/packages/tui/src/extensions/command-picker/index.ts +75 -0
  201. package/packages/tui/src/extensions/file-picker/index.ts +156 -0
  202. package/packages/tui/src/extensions/footer/index.ts +93 -0
  203. package/packages/tui/src/extensions/footer/powerline.ts +87 -0
  204. package/packages/tui/src/extensions/footer/segments.ts +202 -0
  205. package/packages/tui/src/extensions/pim/index.ts +89 -0
  206. package/packages/tui/src/extensions/session-lease/index.ts +253 -0
  207. package/packages/tui/src/extensions/tps/index.ts +118 -0
  208. package/packages/tui/src/extensions/working-indicator/index.ts +75 -0
  209. package/packages/tui/src/extensions/wrapProvider.ts +18 -0
  210. package/packages/tui/src/themes/pim-dark.json +84 -0
  211. package/packages/tui/src/themes/pim-light.json +84 -0
  212. package/packages/tui/src/themes/themeCliArgs.ts +20 -0
  213. package/packages/web/dist/client/apple-touch-icon.png +0 -0
  214. package/packages/web/dist/client/assets/bash-LmRjUKlR.js +1 -0
  215. package/packages/web/dist/client/assets/c-DBVbJFpC.js +1 -0
  216. package/packages/web/dist/client/assets/clojure-CR7dvLJ4.js +1 -0
  217. package/packages/web/dist/client/assets/cmake-CgP6gB4W.js +1 -0
  218. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-B48RJMeK.woff +0 -0
  219. package/packages/web/dist/client/assets/commit-mono-latin-300-italic-DBq5bJCE.woff2 +0 -0
  220. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-B-iV2FbL.woff2 +0 -0
  221. package/packages/web/dist/client/assets/commit-mono-latin-300-normal-kM0OTZCv.woff +0 -0
  222. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-BXinMwCi.woff +0 -0
  223. package/packages/web/dist/client/assets/commit-mono-latin-400-italic-DjSHLl2N.woff2 +0 -0
  224. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-s0S3qwFW.woff +0 -0
  225. package/packages/web/dist/client/assets/commit-mono-latin-400-normal-wzhe4RuD.woff2 +0 -0
  226. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-BuQDXT0M.woff +0 -0
  227. package/packages/web/dist/client/assets/commit-mono-latin-600-normal-SwgSWHaV.woff2 +0 -0
  228. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-1-GU0IUE.woff +0 -0
  229. package/packages/web/dist/client/assets/commit-mono-latin-700-normal-DU8mrtj2.woff2 +0 -0
  230. package/packages/web/dist/client/assets/core-CA0aSzPu.js +3 -0
  231. package/packages/web/dist/client/assets/cpp-N2d8M7Nc.js +1 -0
  232. package/packages/web/dist/client/assets/csharp-yYt0Ikks.js +1 -0
  233. package/packages/web/dist/client/assets/css-D8M8GYaw.js +1 -0
  234. package/packages/web/dist/client/assets/diff-BhdZizse.js +1 -0
  235. package/packages/web/dist/client/assets/dockerfile-C0zufGsU.js +1 -0
  236. package/packages/web/dist/client/assets/elixir-DXIHlGsR.js +1 -0
  237. package/packages/web/dist/client/assets/erlang-BjYI8dhz.js +1 -0
  238. package/packages/web/dist/client/assets/go-CYstVVJV.js +1 -0
  239. package/packages/web/dist/client/assets/graphql-BHHhacaR.js +1 -0
  240. package/packages/web/dist/client/assets/haskell-DUkhXIda.js +1 -0
  241. package/packages/web/dist/client/assets/index-B9be-i9d.css +1 -0
  242. package/packages/web/dist/client/assets/index-CpinM5NK.js +45 -0
  243. package/packages/web/dist/client/assets/ini-B1CroMsf.js +1 -0
  244. package/packages/web/dist/client/assets/java-Dde-Wnb4.js +1 -0
  245. package/packages/web/dist/client/assets/javascript-8AmHuH5O.js +1 -0
  246. package/packages/web/dist/client/assets/json-PmyKWHmm.js +1 -0
  247. package/packages/web/dist/client/assets/kotlin-DgSK8bu-.js +2 -0
  248. package/packages/web/dist/client/assets/less-Bp0mgmpG.js +1 -0
  249. package/packages/web/dist/client/assets/lua-1dXYaL-H.js +1 -0
  250. package/packages/web/dist/client/assets/makefile-BX-o3EIz.js +1 -0
  251. package/packages/web/dist/client/assets/markdown-D6FrnlqJ.js +1 -0
  252. package/packages/web/dist/client/assets/ocaml-B-P1Uyq8.js +1 -0
  253. package/packages/web/dist/client/assets/perl-CvqMZYuf.js +1 -0
  254. package/packages/web/dist/client/assets/php-DuZl5e-L.js +2 -0
  255. package/packages/web/dist/client/assets/powershell-BUe16zE4.js +1 -0
  256. package/packages/web/dist/client/assets/protobuf-DGOW1qT8.js +1 -0
  257. package/packages/web/dist/client/assets/python-gHmu6VPG.js +1 -0
  258. package/packages/web/dist/client/assets/r-DNWPCnjT.js +1 -0
  259. package/packages/web/dist/client/assets/ruby-BGpZ3p4_.js +1 -0
  260. package/packages/web/dist/client/assets/rust-BIKPVoi6.js +1 -0
  261. package/packages/web/dist/client/assets/scala-C9lP4FWY.js +1 -0
  262. package/packages/web/dist/client/assets/scss-gOyOKiXd.js +1 -0
  263. package/packages/web/dist/client/assets/sql-DWRKyvJ-.js +1 -0
  264. package/packages/web/dist/client/assets/swift-BTn9__hL.js +1 -0
  265. package/packages/web/dist/client/assets/typescript-CxkPLmSc.js +1 -0
  266. package/packages/web/dist/client/assets/vim-Dhhcn_CA.js +1 -0
  267. package/packages/web/dist/client/assets/xml-COl5yFSC.js +1 -0
  268. package/packages/web/dist/client/assets/yaml-DjOhhVLG.js +1 -0
  269. package/packages/web/dist/client/favicon.svg +1 -0
  270. package/packages/web/dist/client/icon-192.png +0 -0
  271. package/packages/web/dist/client/icon-512.png +0 -0
  272. package/packages/web/dist/client/icon-maskable.png +0 -0
  273. package/packages/web/dist/client/index.html +24 -0
  274. package/packages/web/dist/client/manifest.webmanifest +21 -0
  275. package/packages/web/dist/client/wordmark.svg +1 -0
@@ -0,0 +1,798 @@
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 { Git, type GitBranch } from "#core/shared/Git";
11
+ import { GitMonitor, type GitRun } from "#core/shared/GitMonitor";
12
+ import { RepoDiff } from "#core/shared/RepoDiff";
13
+ import { ReadCursors } from "#core/session/ReadCursors";
14
+ import type { SessionHost } from "#core/session/SessionHost";
15
+ import { SessionMeta } from "#core/session/SessionMeta";
16
+ import type { SessionRegistry } from "#core/session/SessionRegistry";
17
+ import { PimVersion } from "#core/shared/PimVersion";
18
+ import { SubagentLogs } from "#core/shared/SubagentLogs";
19
+ import type { UpdateOutcome } from "#core/shared/Updater";
20
+ import type { Command } from "#protocol/Command";
21
+ import type { ChangeList, FileDiff, FileLines } from "#protocol/Diff";
22
+ import type {
23
+ ModelView,
24
+ ProjectView,
25
+ SearchHitView,
26
+ ServerEvent,
27
+ SessionSummaryView,
28
+ } from "#protocol/ServerEvent";
29
+ import { ClientConnection } from "./ClientConnection";
30
+ import { Reloader } from "./Reloader";
31
+ import { SessionCatalogue } from "./SessionCatalogue";
32
+ import { SessionProjection } from "./SessionProjection";
33
+ import { SessionStream } from "./SessionStream";
34
+ import { StaticClient } from "./StaticClient";
35
+ import { AttachmentEndpoint } from "./AttachmentEndpoint";
36
+ import { ImageEndpoint } from "./ImageEndpoint";
37
+
38
+ export type WsGatewayDeps = {
39
+ readonly registry: SessionRegistry;
40
+ /** Loopback by default and never `0.0.0.0`: full host access, no auth. */
41
+ readonly hostname?: string;
42
+ /** 0 asks the OS for a free port; read it back from `port`. */
43
+ readonly port?: number;
44
+ /** Where uploaded bytes are kept; defaults to `~/.pim/attachments`. */
45
+ readonly attachmentsRoot?: string;
46
+ /** Where `read` spilled the pictures it showed the model; defaults to `~/.pim/cache`. */
47
+ readonly imagesRoot?: string;
48
+ /** Where the read cursors live; defaults to `~/.pim/read.json`. */
49
+ readonly readCursorsPath?: string;
50
+ /** Where the per-session and per-project overrides live; defaults to `~/.pim/sessions.json`. */
51
+ readonly sessionMetaPath?: string;
52
+ /** How often a session file is polled where `fs.watch` says nothing; the default is a second. */
53
+ readonly pollMs?: number;
54
+ /** The built web client; defaults to the bundle shipped beside this package. */
55
+ readonly clientDir?: string;
56
+ /** Runs the update a `reload` asks for, reporting each step as it starts. */
57
+ readonly update?: (onStep: (label: string) => void) => Promise<UpdateOutcome>;
58
+ /** Ends this process so the supervisor replaces it with the updated code. */
59
+ readonly shutdown?: () => Promise<void>;
60
+ };
61
+
62
+ type Outcome = {
63
+ readonly error?: string;
64
+ readonly items?: readonly PickerItem[];
65
+ readonly sessions?: readonly SessionSummaryView[];
66
+ readonly projects?: readonly ProjectView[];
67
+ readonly hits?: readonly SearchHitView[];
68
+ readonly dropped?: readonly string[];
69
+ readonly scanned?: number;
70
+ readonly models?: readonly ModelView[];
71
+ readonly thinkingLevels?: readonly string[];
72
+ readonly directory?: DirectoryListing;
73
+ readonly branches?: readonly GitBranch[];
74
+ readonly commit?: { readonly sha: string };
75
+ readonly changes?: ChangeList;
76
+ readonly fileDiff?: FileDiff;
77
+ readonly fileLines?: FileLines;
78
+ readonly restored?: readonly string[];
79
+ readonly after?: () => void;
80
+ };
81
+
82
+ type StreamTarget = {
83
+ readonly sessionId?: string;
84
+ readonly cwd?: string;
85
+ readonly like?: SessionHost;
86
+ };
87
+
88
+ export const DEFAULT_PORT = 4319;
89
+
90
+ export const DEFAULT_HOSTNAME = "127.0.0.1";
91
+
92
+ // Bun 1.3.14 never frees the slot of a server-closed socket, so an unbounded `Server.stop(true)` hangs.
93
+ const STOP_GRACE_MS = 250;
94
+
95
+ /** The transport half of pim-server: a WebSocket endpoint over the session runtime in `core`. */
96
+ export class WsGateway {
97
+ private readonly registry: SessionRegistry;
98
+ private readonly hostname: string;
99
+ private readonly requestedPort: number;
100
+ private readonly uploads: AttachmentEndpoint;
101
+ private readonly images: ImageEndpoint;
102
+ private readonly catalogue: SessionCatalogue;
103
+ private readonly meta: SessionMeta;
104
+ private readonly client: StaticClient;
105
+ private readonly streams = new Map<string, SessionStream>();
106
+ private readonly opening = new Map<string, Promise<SessionStream>>();
107
+ private readonly connections = new Map<
108
+ ServerWebSocket<undefined>,
109
+ ClientConnection
110
+ >();
111
+ private readonly reloader: Reloader;
112
+ private readonly git = new GitMonitor();
113
+ private readonly pollMs: number | undefined;
114
+ private readonly busyRepos = new Set<string>();
115
+ private versionsRead: Promise<readonly [string, string]> | undefined;
116
+ private server: Server<undefined> | undefined;
117
+
118
+ public constructor(deps: WsGatewayDeps) {
119
+ this.registry = deps.registry;
120
+ this.hostname = deps.hostname ?? DEFAULT_HOSTNAME;
121
+ this.requestedPort = deps.port ?? DEFAULT_PORT;
122
+ this.pollMs = deps.pollMs;
123
+ this.uploads = new AttachmentEndpoint(
124
+ deps.attachmentsRoot === undefined ? {} : { root: deps.attachmentsRoot }
125
+ );
126
+ this.images = new ImageEndpoint(
127
+ deps.imagesRoot === undefined ? {} : { root: deps.imagesRoot }
128
+ );
129
+ this.meta = new SessionMeta(deps.sessionMetaPath);
130
+ this.catalogue = new SessionCatalogue({
131
+ registry: deps.registry,
132
+ cursors: new ReadCursors(deps.readCursorsPath),
133
+ meta: this.meta,
134
+ liveStatus: (sessionId) => this.streams.get(sessionId)?.host.status,
135
+ liveSessionIds: () => this.streams.keys(),
136
+ isBeingRead: (sessionId) => this.isBeingRead(sessionId),
137
+ announce: (event) => {
138
+ this.broadcast(event);
139
+ },
140
+ });
141
+ this.client = new StaticClient(deps.clientDir);
142
+ this.reloader = new Reloader({
143
+ announce: (event) => {
144
+ this.broadcast(event);
145
+ },
146
+ update: deps.update,
147
+ shutdown: deps.shutdown,
148
+ });
149
+ }
150
+
151
+ public get port(): number {
152
+ const port = this.server?.port;
153
+ if (port === undefined) {
154
+ throw new Error("WsGateway is not listening");
155
+ }
156
+ return port;
157
+ }
158
+
159
+ public get url(): string {
160
+ return `ws://${this.hostname}:${this.port}`;
161
+ }
162
+
163
+ public start(): void {
164
+ if (this.server) {
165
+ return;
166
+ }
167
+ this.server = Bun.serve({
168
+ hostname: this.hostname,
169
+ port: this.requestedPort,
170
+ idleTimeout: 0,
171
+ fetch: (req, server) => {
172
+ const { pathname } = new URL(req.url);
173
+ if (pathname === "/health") {
174
+ return this.versions().then(([pimVersion]) =>
175
+ Response.json({ ok: true, pimVersion })
176
+ );
177
+ }
178
+ if (AttachmentEndpoint.owns(pathname)) {
179
+ return this.uploads.handle(req);
180
+ }
181
+ if (ImageEndpoint.owns(pathname)) {
182
+ return this.images.handle(req);
183
+ }
184
+ // Try the upgrade first: the socket must answer on every path.
185
+ return server.upgrade(req) ? undefined : this.client.handle(req);
186
+ },
187
+ websocket: {
188
+ backpressureLimit: 8 << 20,
189
+ closeOnBackpressureLimit: false,
190
+ // Dedicated, so the window survives between frames: the repeated event envelope is most of a delta.
191
+ perMessageDeflate: { compress: "dedicated", decompress: "dedicated" },
192
+ open: (ws) => {
193
+ this.connections.set(ws, new ClientConnection(ws));
194
+ this.syncWatches();
195
+ },
196
+ message: (ws, raw) => {
197
+ void this.onMessage(ws, raw);
198
+ },
199
+ drain: (ws) => {
200
+ this.connections.get(ws)?.onDrain();
201
+ },
202
+ close: (ws) => {
203
+ this.connections.get(ws)?.close();
204
+ this.connections.delete(ws);
205
+ this.syncWatches();
206
+ },
207
+ },
208
+ });
209
+ }
210
+
211
+ public async stop(): Promise<void> {
212
+ for (const stream of this.streams.values()) {
213
+ stream.dispose();
214
+ }
215
+ this.streams.clear();
216
+ this.opening.clear();
217
+ this.git.dispose();
218
+ this.busyRepos.clear();
219
+ this.catalogue.watch(false);
220
+ this.catalogue.clear();
221
+ await this.catalogue.flush();
222
+ for (const connection of this.connections.values()) {
223
+ connection.close();
224
+ }
225
+ this.connections.clear();
226
+ const server = this.server;
227
+ this.server = undefined;
228
+ if (server) {
229
+ await Promise.race([server.stop(true), Bun.sleep(STOP_GRACE_MS)]);
230
+ }
231
+ }
232
+
233
+ private async onMessage(
234
+ ws: ServerWebSocket<undefined>,
235
+ raw: string | Buffer
236
+ ): Promise<void> {
237
+ const connection = this.connections.get(ws);
238
+ if (!connection) {
239
+ return;
240
+ }
241
+ let command: Command;
242
+ try {
243
+ command = JSON.parse(String(raw)) as Command;
244
+ } catch {
245
+ connection.send({ type: "error", message: "malformed frame" });
246
+ return;
247
+ }
248
+ if (typeof command?.id !== "string" || typeof command?.type !== "string") {
249
+ connection.send({ type: "error", message: "frame is not a command" });
250
+ return;
251
+ }
252
+ try {
253
+ const { error, after, ...answer } = await this.dispatch(
254
+ connection,
255
+ command
256
+ );
257
+ connection.send({
258
+ type: "response",
259
+ id: command.id,
260
+ success: error === undefined,
261
+ ...(error === undefined ? {} : { error }),
262
+ ...answer,
263
+ });
264
+ after?.();
265
+ } catch (err) {
266
+ connection.send({
267
+ type: "response",
268
+ id: command.id,
269
+ success: false,
270
+ error: (err as Error).message ?? String(err),
271
+ });
272
+ }
273
+ }
274
+
275
+ private async dispatch(
276
+ connection: ClientConnection,
277
+ command: Command
278
+ ): Promise<Outcome> {
279
+ switch (command.type) {
280
+ case "attach":
281
+ return await this.attach(connection, command);
282
+ case "attention": {
283
+ const regained = command.value && !connection.attentive;
284
+ connection.setAttentive(command.value);
285
+ const sessionId = connection.sessionId;
286
+ if (regained && sessionId !== undefined) {
287
+ await this.catalogue.markRead(sessionId);
288
+ }
289
+ return {};
290
+ }
291
+ case "list_sessions":
292
+ return await this.catalogue.list(command);
293
+ case "search_sessions":
294
+ return await this.catalogue.search(command);
295
+ // The four below take a session this server may never have opened: a row
296
+ // is archived or renamed from the sidebar without being attached to, so
297
+ // none of them may reach for a stream. A sidecar write moves no session
298
+ // file and produces no `sessions_changed`, so the broadcast each ends
299
+ // with is the only word a listing already in a client's hands gets.
300
+ case "set_session_name": {
301
+ const name = await this.registry.setName(
302
+ command.sessionId,
303
+ command.value
304
+ );
305
+ this.broadcast({
306
+ type: "session_meta",
307
+ sessionId: command.sessionId,
308
+ name: name ?? null,
309
+ });
310
+ return {};
311
+ }
312
+ case "set_session_archived":
313
+ await this.meta.setArchived(command.sessionId, command.value);
314
+ this.broadcast({
315
+ type: "session_meta",
316
+ sessionId: command.sessionId,
317
+ archived: command.value,
318
+ });
319
+ return {};
320
+ case "set_session_unread":
321
+ await this.meta.setUnread(command.sessionId, command.value);
322
+ this.broadcast({
323
+ type: "session_meta",
324
+ sessionId: command.sessionId,
325
+ unread: command.value,
326
+ });
327
+ return {};
328
+ case "set_project_pinned":
329
+ await this.meta.setPinned(command.cwd, command.value);
330
+ this.broadcast({
331
+ type: "project_meta",
332
+ cwd: command.cwd,
333
+ pinned: command.value,
334
+ });
335
+ // The flag and the place it takes are one fact to a sidebar; a pin
336
+ // that arrived without a rank would sort by nothing until the next listing.
337
+ this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
338
+ return {};
339
+ case "set_project_expanded":
340
+ await this.meta.setExpanded(command.cwd, command.value);
341
+ this.broadcast({
342
+ type: "project_meta",
343
+ cwd: command.cwd,
344
+ expanded: command.value,
345
+ });
346
+ return {};
347
+ case "set_project_label":
348
+ await this.meta.setLabel(command.cwd, command.value);
349
+ this.broadcast({
350
+ type: "project_meta",
351
+ cwd: command.cwd,
352
+ label: command.value,
353
+ });
354
+ return {};
355
+ case "set_pin_order":
356
+ await this.meta.setPinOrder(command.order);
357
+ this.broadcast({ type: "pins_changed", order: await this.meta.pins() });
358
+ return {};
359
+ case "list_models":
360
+ return {
361
+ models: this.registry.models(),
362
+ thinkingLevels:
363
+ this.streamFor(connection)?.host.supportedThinkingLevels ?? [],
364
+ };
365
+ case "list_dirs":
366
+ return { directory: await Directories.list(command.path) };
367
+ case "refresh_git": {
368
+ const stream = this.requireStream(connection);
369
+ await stream.refreshGit(command.fetch === true);
370
+ return {};
371
+ }
372
+ case "list_branches":
373
+ return {
374
+ branches: await Git.listBranches(
375
+ this.requireStream(connection).host.cwd
376
+ ),
377
+ };
378
+ // Reading the repository never moves it, so neither of these takes the `repoBusy` refusal.
379
+ case "list_changes":
380
+ return {
381
+ changes: await RepoDiff.listChanges(
382
+ this.requireStream(connection).host.cwd,
383
+ command.base,
384
+ this.git
385
+ ),
386
+ };
387
+ case "file_diff":
388
+ return {
389
+ fileDiff: await RepoDiff.fileDiff(
390
+ this.requireStream(connection).host.cwd,
391
+ command.base,
392
+ command.path,
393
+ this.git,
394
+ command.context
395
+ ),
396
+ };
397
+ case "read_lines":
398
+ return {
399
+ fileLines: await RepoDiff.readLines(
400
+ this.requireStream(connection).host.cwd,
401
+ command.base,
402
+ command.path,
403
+ command.spans,
404
+ this.git
405
+ ),
406
+ };
407
+ case "checkout": {
408
+ const stream = this.requireStream(connection);
409
+ return await this.runGit(stream, true, (cwd) =>
410
+ Git.checkout(cwd, command.branch)
411
+ );
412
+ }
413
+ case "commit":
414
+ return await this.commit(this.requireStream(connection), command);
415
+ case "pull":
416
+ case "push":
417
+ return await this.runGit(
418
+ this.requireStream(connection),
419
+ command.type === "pull",
420
+ command.type === "pull" ? Git.pull : Git.push
421
+ );
422
+ case "unwatch_subagent":
423
+ connection.unwatchSubagent(command.callId);
424
+ return {};
425
+ case "reload":
426
+ return this.reload(command.force === true);
427
+ case "user_message":
428
+ this.promptWithAttachments(this.requireStream(connection), command);
429
+ return {};
430
+ case "cancel": {
431
+ const { cancelled, restored } =
432
+ await this.requireStream(connection).host.cancel();
433
+ return cancelled ? { restored } : { error: "nothing to cancel" };
434
+ }
435
+ case "dequeue":
436
+ return { restored: this.requireStream(connection).host.takeBack() };
437
+ case "set_cwd": {
438
+ const stream = this.requireStream(connection);
439
+ const result = await stream.host.setCwd(command.value);
440
+ stream.syncGit();
441
+ stream.push(stream.sessionState());
442
+ if (!result.ok) {
443
+ return { error: result.error };
444
+ }
445
+ stream.invalidatePickers("all");
446
+ return {};
447
+ }
448
+ case "set_model": {
449
+ const stream = this.requireStream(connection);
450
+ const result = await stream.host.setModel(command.value);
451
+ stream.push(stream.sessionState());
452
+ return result.ok
453
+ ? {}
454
+ : {
455
+ error: `${result.kind} model "${command.value}"; candidates: ${result.candidates.join(", ")}`,
456
+ };
457
+ }
458
+ case "set_thinking": {
459
+ const stream = this.requireStream(connection);
460
+ await stream.host.setThinkingLevel(command.value as ThinkingLevel);
461
+ stream.push(stream.sessionState());
462
+ return {};
463
+ }
464
+ case "pick_files":
465
+ return {
466
+ items: await this.requireStream(connection).picker.files(
467
+ command.query,
468
+ command.limit
469
+ ),
470
+ };
471
+ case "pick_commands":
472
+ return {
473
+ items: this.requireStream(connection).picker.commands(
474
+ command.query,
475
+ command.limit
476
+ ),
477
+ };
478
+ case "watch_subagent":
479
+ return await this.watchSubagent(
480
+ connection,
481
+ this.requireStream(connection),
482
+ command
483
+ );
484
+ default:
485
+ this.requireStream(connection);
486
+ return { error: `unknown command: ${(command as Command).type}` };
487
+ }
488
+ }
489
+
490
+ private streamFor(connection: ClientConnection): SessionStream | undefined {
491
+ const sessionId = connection.sessionId;
492
+ return sessionId ? this.streams.get(sessionId) : undefined;
493
+ }
494
+
495
+ private requireStream(connection: ClientConnection): SessionStream {
496
+ const stream = this.streamFor(connection);
497
+ if (!stream) {
498
+ throw new Error("not attached: send `attach` first");
499
+ }
500
+ return stream;
501
+ }
502
+
503
+ // Derive the log path from the parent session and call id: a client-sent path reads arbitrary JSONL off this machine.
504
+ private async watchSubagent(
505
+ connection: ClientConnection,
506
+ stream: SessionStream,
507
+ command: Command & { readonly type: "watch_subagent" }
508
+ ): Promise<Outcome> {
509
+ if (command.sessionId !== stream.sessionId) {
510
+ return {
511
+ error: `not attached to session ${command.sessionId}; a watch reads a child of the attached session only`,
512
+ };
513
+ }
514
+ const path = SubagentLogs.pathFor(stream.sessionId, command.callId);
515
+ if (path === null) {
516
+ return { error: `malformed call id: ${command.callId}` };
517
+ }
518
+ // A running call may not have written its log yet; the watch waits for it.
519
+ if (!stream.isRunning(command.callId) && !(await Bun.file(path).exists())) {
520
+ return { error: `no subagent log for call ${command.callId}` };
521
+ }
522
+ await connection.watchSubagent(
523
+ command.callId,
524
+ new SessionProjection(path, () => stream.host.cwd),
525
+ command.fromSeq
526
+ );
527
+ return {};
528
+ }
529
+
530
+ private async attach(
531
+ connection: ClientConnection,
532
+ command: Command & { readonly type: "attach" }
533
+ ): Promise<Outcome> {
534
+ const attentive = command.attentive !== false;
535
+ const like =
536
+ command.like === undefined
537
+ ? undefined
538
+ : this.streams.get(command.like)?.host;
539
+ const [stream, [pimVersion, piVersion]] = await Promise.all([
540
+ this.ensureStream({
541
+ ...(command.sessionId === undefined
542
+ ? {}
543
+ : { sessionId: command.sessionId }),
544
+ ...(command.cwd === undefined ? {} : { cwd: command.cwd }),
545
+ ...(like === undefined ? {} : { like }),
546
+ }),
547
+ this.versions(),
548
+ ]);
549
+ connection.send({
550
+ type: "attached",
551
+ sessionId: stream.sessionId,
552
+ cwd: stream.host.cwd,
553
+ head: await stream.refresh(),
554
+ pimVersion,
555
+ piVersion,
556
+ });
557
+ await connection.attach(stream, command.fromSeq, attentive);
558
+ this.syncWatches();
559
+ if (attentive) {
560
+ await this.catalogue.markRead(stream.sessionId);
561
+ }
562
+ return {};
563
+ }
564
+
565
+ /** A watch costs a poll, so only attached sessions get one — and the catalogue only while someone is connected. */
566
+ private syncWatches(): void {
567
+ for (const [sessionId, stream] of this.streams) {
568
+ // Attachment, not attention: a hidden tab that lost its watches would stop hearing about the session.
569
+ stream.watchFiles(this.isAttached(sessionId));
570
+ }
571
+ this.catalogue.watch(this.connections.size > 0);
572
+ }
573
+
574
+ /**
575
+ * An operation that moves the working tree cannot run under an agent that
576
+ * may be halfway through an edit; `push` leaves the tree alone, so it can.
577
+ */
578
+ private async runGit<T = never>(
579
+ stream: SessionStream,
580
+ movesTree: boolean,
581
+ operation: (cwd: string) => Promise<GitRun<T>>
582
+ ): Promise<Outcome & { readonly value?: T }> {
583
+ const cwd = stream.host.cwd;
584
+ if (movesTree && this.repoBusy(cwd)) {
585
+ return {
586
+ error: `an agent is working in ${cwd}; wait for its turn to end`,
587
+ };
588
+ }
589
+ const result = await this.git.run<T>(cwd, () => operation(cwd));
590
+ if (!result.ok) {
591
+ return { error: result.error };
592
+ }
593
+ return result.value === undefined ? {} : { value: result.value };
594
+ }
595
+
596
+ private async commit(
597
+ stream: SessionStream,
598
+ command: Command & { readonly type: "commit" }
599
+ ): Promise<Outcome> {
600
+ const { value, ...outcome } = await this.runGit<string>(
601
+ stream,
602
+ true,
603
+ async (cwd) => {
604
+ const result = await Git.commit(cwd, {
605
+ message: command.message,
606
+ paths: command.paths,
607
+ });
608
+ return result.ok
609
+ ? { ok: true, value: result.sha }
610
+ : { ok: false, error: result.error };
611
+ }
612
+ );
613
+ return value === undefined ? outcome : { commit: { sha: value } };
614
+ }
615
+
616
+ private repoBusy(cwd: string): boolean {
617
+ for (const stream of this.streams.values()) {
618
+ if (stream.host.cwd === cwd && stream.host.status !== "idle") {
619
+ return true;
620
+ }
621
+ }
622
+ return false;
623
+ }
624
+
625
+ /** A turn starting anywhere freezes the branch menu of every session beside it, which only their own state can say. */
626
+ private syncRepoBusy(cwd: string, source: string): void {
627
+ const busy = this.repoBusy(cwd);
628
+ if (this.busyRepos.has(cwd) === busy) {
629
+ return;
630
+ }
631
+ if (busy) {
632
+ this.busyRepos.add(cwd);
633
+ } else {
634
+ this.busyRepos.delete(cwd);
635
+ }
636
+ for (const [sessionId, stream] of this.streams) {
637
+ if (sessionId !== source && stream.host.cwd === cwd) {
638
+ stream.push(stream.sessionState());
639
+ }
640
+ }
641
+ }
642
+
643
+ private reload(force: boolean): Outcome {
644
+ const busy = [...this.streams.values()]
645
+ .filter((stream) => stream.host.status !== "idle")
646
+ .map((stream) => stream.sessionId);
647
+ if (!force && busy.length > 0) {
648
+ const many = busy.length > 1;
649
+ return {
650
+ 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`,
651
+ };
652
+ }
653
+ return {
654
+ after: () => {
655
+ void this.reloader.start();
656
+ },
657
+ };
658
+ }
659
+
660
+ private versions(): Promise<readonly [string, string]> {
661
+ this.versionsRead ??= Promise.all([PimVersion.current(), PimVersion.pi()]);
662
+ return this.versionsRead;
663
+ }
664
+
665
+ private isAttached(sessionId: string): boolean {
666
+ return this.connections
667
+ .values()
668
+ .some((connection) => connection.sessionId === sessionId);
669
+ }
670
+
671
+ // The TUI never attaches, so reading a session there clears no dot here and a turn it runs trips no mark.
672
+ private isBeingRead(sessionId: string): boolean {
673
+ return this.connections
674
+ .values()
675
+ .some(
676
+ (connection) =>
677
+ connection.sessionId === sessionId && connection.attentive
678
+ );
679
+ }
680
+
681
+ private broadcast(event: ServerEvent): void {
682
+ for (const connection of this.connections.values()) {
683
+ connection.send(event);
684
+ }
685
+ }
686
+
687
+ private async ensureStream(target: StreamTarget): Promise<SessionStream> {
688
+ const sessionId = target.sessionId;
689
+ if (sessionId) {
690
+ const live = this.streams.get(sessionId);
691
+ if (live) {
692
+ return live;
693
+ }
694
+ const pending = this.opening.get(sessionId);
695
+ if (pending) {
696
+ return await pending;
697
+ }
698
+ }
699
+ const build = this.buildStream(target);
700
+ if (sessionId) {
701
+ this.opening.set(sessionId, build);
702
+ }
703
+ try {
704
+ return await build;
705
+ } finally {
706
+ if (sessionId) {
707
+ this.opening.delete(sessionId);
708
+ }
709
+ }
710
+ }
711
+
712
+ private async buildStream(target: StreamTarget): Promise<SessionStream> {
713
+ const sessionId = target.sessionId;
714
+ const host = sessionId
715
+ ? await this.registry.open(sessionId)
716
+ : await this.registry.create({
717
+ ...(target.cwd === undefined ? {} : { cwd: target.cwd }),
718
+ ...(target.like === undefined ? {} : { like: target.like }),
719
+ });
720
+ // Pi assigns the id and the session file only once an agent exists.
721
+ const agent = host.agentSession ?? (await host.ensureAgent());
722
+ const id = agent.sessionId;
723
+ const path = agent.sessionFile ?? host.settings.sessionPath;
724
+ if (!path) {
725
+ throw new Error(`session ${sessionId ?? "(new)"} has no agent`);
726
+ }
727
+ const existing = this.streams.get(id);
728
+ if (existing) {
729
+ return existing;
730
+ }
731
+ const stream = new SessionStream(id, host, path, {
732
+ git: this.git,
733
+ repoBusy: () => this.repoBusy(host.cwd),
734
+ pollMs: this.pollMs,
735
+ });
736
+ stream.start();
737
+ this.catalogue.track(id, host.status);
738
+ stream.subscribe((event) => {
739
+ if (event.type === "session_state") {
740
+ this.catalogue.onStatus(id, event.status);
741
+ this.syncRepoBusy(stream.host.cwd, id);
742
+ }
743
+ });
744
+ this.streams.set(id, stream);
745
+ return stream;
746
+ }
747
+
748
+ private promptWithAttachments(
749
+ stream: SessionStream,
750
+ command: Command & { readonly type: "user_message" }
751
+ ): void {
752
+ const taken = this.uploads.take(
753
+ stream.sessionId,
754
+ (command.attachments ?? []).map((ref) => ref.id)
755
+ );
756
+ const { lines, images } = Attachments.render(taken);
757
+ const text = [command.text, ...lines].filter(Boolean).join("\n\n").trim();
758
+ this.prompt(stream, text, images);
759
+ }
760
+
761
+ // Merge into the queued message: pi holds at most one, so a second queued send would be unreachable.
762
+ private prompt(
763
+ stream: SessionStream,
764
+ text: string,
765
+ images: readonly ImageContent[] = []
766
+ ): void {
767
+ const attached = images.length === 0 ? {} : { images: [...images] };
768
+ const host = stream.host;
769
+ const agent = host.agentSession;
770
+ const failed = (err: unknown, what: string): void => {
771
+ console.error(`[gateway] ${what} failed:`, err);
772
+ stream.push({
773
+ type: "error",
774
+ message: (err as Error).message || String(err),
775
+ });
776
+ };
777
+ if (agent && host.isStreaming) {
778
+ const queued = [...host.takeBack(), text].join("\n\n");
779
+ void agent
780
+ .prompt(queued, {
781
+ streamingBehavior: "steer",
782
+ source: "rpc",
783
+ ...attached,
784
+ })
785
+ .catch((err: unknown) => {
786
+ failed(err, "steer");
787
+ });
788
+ return;
789
+ }
790
+ void host
791
+ .run(async (session) => {
792
+ await session.prompt(text, { source: "rpc", ...attached });
793
+ })
794
+ .catch((err: unknown) => {
795
+ failed(err, "turn");
796
+ });
797
+ }
798
+ }