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,279 @@
1
+ import { readFileSync, unlinkSync } from "node:fs";
2
+ import { mkdir, open, stat, unlink, utimes } from "node:fs/promises";
3
+ import { hostname } from "node:os";
4
+ import { dirname } from "node:path";
5
+
6
+ import { FileWatch } from "../shared/FileWatch";
7
+ import { FsErrors } from "../shared/FsErrors";
8
+ import { Fs } from "../shared/Fs";
9
+ import { Json } from "../shared/Json";
10
+
11
+ export type LeaseFrontend = "tui" | "daemon";
12
+
13
+ /** What one holder wrote into `<sessionPath>.lease`. */
14
+ export type LeaseRecord = {
15
+ readonly pid: number;
16
+ readonly hostname: string;
17
+ readonly frontend: LeaseFrontend;
18
+ readonly startedAt: number;
19
+ };
20
+
21
+ export type LeaseHandle = {
22
+ release(): Promise<void>;
23
+ };
24
+
25
+ export type AcquireResult =
26
+ | { readonly ok: true; readonly handle: LeaseHandle }
27
+ /** Absent when the holder's file is torn or vanished mid-read. */
28
+ | { readonly ok: false; readonly holder?: LeaseRecord };
29
+
30
+ export type LeaseOptions = {
31
+ readonly heartbeatMs?: number;
32
+ readonly pollMs?: number;
33
+ readonly timeoutMs?: number;
34
+ /** Called with the current holder each time an acquire is denied and another poll follows. */
35
+ readonly onBlocked?: (holder: LeaseRecord | undefined) => void;
36
+ };
37
+
38
+ const HEARTBEAT_MS = 5_000;
39
+ const STALE_MS = 30_000;
40
+ const POLL_MS = 250;
41
+ const TIMEOUT_MS = 5 * 60_000;
42
+ const STEAL_ATTEMPTS = 3;
43
+ const STEAL_BACKOFF_MS = 50;
44
+
45
+ const HOST = hostname();
46
+
47
+ const held = new Set<string>();
48
+ let exitHooked = false;
49
+
50
+ function pathFor(sessionPath: string): string {
51
+ return `${sessionPath}.lease`;
52
+ }
53
+
54
+ function toRecord(value: unknown): LeaseRecord | undefined {
55
+ const raw = Json.asRecord(value);
56
+ if (
57
+ typeof raw?.pid !== "number" ||
58
+ typeof raw.hostname !== "string" ||
59
+ typeof raw.startedAt !== "number" ||
60
+ (raw.frontend !== "tui" && raw.frontend !== "daemon")
61
+ ) {
62
+ return undefined;
63
+ }
64
+ return {
65
+ pid: raw.pid,
66
+ hostname: raw.hostname,
67
+ frontend: raw.frontend,
68
+ startedAt: raw.startedAt,
69
+ };
70
+ }
71
+
72
+ async function recordAt(path: string): Promise<LeaseRecord | undefined> {
73
+ return toRecord(await Fs.readJsonOr<unknown>(path, undefined));
74
+ }
75
+
76
+ /** The current holder, or undefined when the file is absent, torn, or half-written. */
77
+ async function read(sessionPath: string): Promise<LeaseRecord | undefined> {
78
+ return await recordAt(pathFor(sessionPath));
79
+ }
80
+
81
+ function isAlive(pid: number): boolean {
82
+ try {
83
+ process.kill(pid, 0);
84
+ return true;
85
+ } catch (err) {
86
+ // EPERM is someone else's live process.
87
+ return FsErrors.code(err) !== "ESRCH";
88
+ }
89
+ }
90
+
91
+ /** A holder is stale once its pid is gone, or once it stops heartbeating; never across hosts. */
92
+ function isStale(
93
+ record: LeaseRecord,
94
+ mtimeMs: number,
95
+ now = Date.now()
96
+ ): boolean {
97
+ if (record.hostname !== HOST) {
98
+ return false;
99
+ }
100
+ return !isAlive(record.pid) || now - mtimeMs > STALE_MS;
101
+ }
102
+
103
+ /** Whether this very process wrote that record — the one holder allowed to release it. */
104
+ function isOurs(record: LeaseRecord | undefined): boolean {
105
+ return record?.pid === process.pid && record.hostname === HOST;
106
+ }
107
+
108
+ async function stealable(
109
+ path: string,
110
+ record: LeaseRecord | undefined
111
+ ): Promise<boolean> {
112
+ const mtimeMs = await stat(path).then(
113
+ (stats) => stats.mtimeMs,
114
+ () => undefined
115
+ );
116
+ if (mtimeMs === undefined) {
117
+ return true;
118
+ }
119
+ // A torn file is a holder mid-write until it stops being refreshed.
120
+ return record === undefined
121
+ ? Date.now() - mtimeMs > STALE_MS
122
+ : isStale(record, mtimeMs);
123
+ }
124
+
125
+ async function claim(
126
+ path: string,
127
+ frontend: LeaseFrontend
128
+ ): Promise<LeaseRecord | undefined> {
129
+ const record: LeaseRecord = {
130
+ pid: process.pid,
131
+ hostname: HOST,
132
+ frontend,
133
+ startedAt: Date.now(),
134
+ };
135
+ const file = await open(path, "wx").catch((err: unknown) => {
136
+ if (FsErrors.code(err) === "EEXIST") {
137
+ return undefined;
138
+ }
139
+ // The session's directory is pi's to create, and a lease can precede the file it guards.
140
+ if (FsErrors.code(err) === "ENOENT") {
141
+ return mkdir(dirname(path), { recursive: true }).then(() =>
142
+ open(path, "wx")
143
+ );
144
+ }
145
+ throw err;
146
+ });
147
+ if (file === undefined) {
148
+ return undefined;
149
+ }
150
+ try {
151
+ await file.writeFile(`${JSON.stringify(record)}\n`);
152
+ } finally {
153
+ await file.close();
154
+ }
155
+ return record;
156
+ }
157
+
158
+ function hookExit(): void {
159
+ if (exitHooked) {
160
+ return;
161
+ }
162
+ exitHooked = true;
163
+ process.on("exit", () => {
164
+ for (const path of held) {
165
+ try {
166
+ if (isOurs(toRecord(Json.tryParseJson(readFileSync(path, "utf8"))))) {
167
+ unlinkSync(path);
168
+ }
169
+ } catch {}
170
+ }
171
+ });
172
+ }
173
+
174
+ function handleFor(path: string, options: LeaseOptions): LeaseHandle {
175
+ held.add(path);
176
+ hookExit();
177
+ // Bump mtime rather than rewrite: a rename would clobber whoever holds the file next.
178
+ const beat = setInterval(() => {
179
+ const now = new Date();
180
+ void utimes(path, now, now).catch(() => undefined);
181
+ }, options.heartbeatMs ?? HEARTBEAT_MS);
182
+ beat.unref?.();
183
+
184
+ let released = false;
185
+ return {
186
+ release: async (): Promise<void> => {
187
+ if (released) {
188
+ return;
189
+ }
190
+ released = true;
191
+ clearInterval(beat);
192
+ held.delete(path);
193
+ if (isOurs(await recordAt(path))) {
194
+ await unlink(path).catch(() => undefined);
195
+ }
196
+ },
197
+ };
198
+ }
199
+
200
+ /** Create-exclusive, stealing only a lease whose holder is provably gone. */
201
+ async function acquire(
202
+ sessionPath: string,
203
+ frontend: LeaseFrontend,
204
+ options: LeaseOptions = {}
205
+ ): Promise<AcquireResult> {
206
+ const path = pathFor(sessionPath);
207
+ for (let attempt = 0; ; attempt++) {
208
+ if (await claim(path, frontend)) {
209
+ return { ok: true, handle: handleFor(path, options) };
210
+ }
211
+ const holder = await recordAt(path);
212
+ if (attempt >= STEAL_ATTEMPTS || !(await stealable(path, holder))) {
213
+ return holder === undefined ? { ok: false } : { ok: false, holder };
214
+ }
215
+ await unlink(path).catch(() => undefined);
216
+ await Bun.sleep(STEAL_BACKOFF_MS);
217
+ }
218
+ }
219
+
220
+ /** Polls until the lease is free, the deadline passes, or the holder is stealable. */
221
+ async function waitFor(
222
+ sessionPath: string,
223
+ frontend: LeaseFrontend,
224
+ options: LeaseOptions = {}
225
+ ): Promise<AcquireResult> {
226
+ const deadline = Date.now() + (options.timeoutMs ?? TIMEOUT_MS);
227
+ while (true) {
228
+ const result = await acquire(sessionPath, frontend, options);
229
+ if (result.ok || Date.now() >= deadline) {
230
+ return result;
231
+ }
232
+ options.onBlocked?.(result.holder);
233
+ await Bun.sleep(options.pollMs ?? POLL_MS);
234
+ }
235
+ }
236
+
237
+ function denial(result: { readonly holder?: LeaseRecord }): string {
238
+ const holder = result.holder;
239
+ const who =
240
+ holder === undefined
241
+ ? "another process"
242
+ : `${holder.frontend} (pid ${holder.pid})`;
243
+ return `Session is busy: ${who} still holds its turn lease.`;
244
+ }
245
+
246
+ /** The only safe way to take a lease: waits for it, and always gives it back. */
247
+ async function hold<T>(
248
+ sessionPath: string,
249
+ frontend: LeaseFrontend,
250
+ work: () => Promise<T>,
251
+ options: LeaseOptions = {}
252
+ ): Promise<T> {
253
+ const result = await waitFor(sessionPath, frontend, options);
254
+ if (!result.ok) {
255
+ throw new Error(denial(result));
256
+ }
257
+ try {
258
+ return await work();
259
+ } finally {
260
+ await result.handle.release();
261
+ }
262
+ }
263
+
264
+ /** Fires whenever the lease appears, changes hands, or is released. */
265
+ function watch(sessionPath: string, onChange: () => void): () => void {
266
+ return FileWatch.file(pathFor(sessionPath), onChange);
267
+ }
268
+
269
+ /** A turn lease over one session file, held as a sibling `<sessionPath>.lease`. */
270
+ export const SessionLease = {
271
+ pathFor,
272
+ read,
273
+ acquire,
274
+ hold,
275
+ waitFor,
276
+ watch,
277
+ isStale,
278
+ isOurs,
279
+ };
@@ -0,0 +1,267 @@
1
+ import { join } from "node:path";
2
+
3
+ import { Fs } from "../shared/Fs";
4
+ import { Json } from "../shared/Json";
5
+ import { Paths } from "../shared/Paths";
6
+
7
+ export type SessionEntry = {
8
+ readonly archived?: boolean;
9
+ /** Sticky: set by hand, and only a read clears it. */
10
+ readonly unread?: boolean;
11
+ };
12
+
13
+ export type ProjectEntry = {
14
+ readonly pinned?: boolean;
15
+ /** The sidebar group stands unfolded; absent is folded, which is where a project starts. */
16
+ readonly expanded?: boolean;
17
+ /** What to call the directory instead of its base name; the directory itself is never touched. */
18
+ readonly label?: string;
19
+ };
20
+
21
+ /** The pin flags and the order they are shown in, from one read of the file. */
22
+ export type Pinning = {
23
+ /** Keyed by absolute cwd. */
24
+ readonly projects: ReadonlyMap<string, ProjectEntry>;
25
+ /** The pinned directories, in the order they are shown. */
26
+ readonly order: readonly string[];
27
+ };
28
+
29
+ type Stored = {
30
+ readonly version: 1;
31
+ readonly sessions: Record<string, SessionEntry>;
32
+ readonly projects: Record<string, ProjectEntry>;
33
+ /**
34
+ * Where the pinned directories sort, and only that: `projects` stays the
35
+ * truth of whether one is pinned. An entry here that is not pinned is
36
+ * ignored and a pin it has never heard of still sorts, so a file written by
37
+ * a pim that predates the order — or by one that does not write it — needs
38
+ * no migration.
39
+ */
40
+ readonly pins: readonly string[];
41
+ };
42
+
43
+ type Loaded = {
44
+ readonly sessions: Map<string, SessionEntry>;
45
+ readonly projects: Map<string, ProjectEntry>;
46
+ pins: string[];
47
+ };
48
+
49
+ const NONE: SessionEntry = {};
50
+
51
+ /** Per-session and per-project overrides, shared by every surface on a machine. */
52
+ export class SessionMeta {
53
+ private readonly file: string;
54
+ private readonly writes = Fs.serialised();
55
+
56
+ /** Defaults to `~/.pim/sessions.json`. */
57
+ public constructor(path?: string) {
58
+ this.file = path ?? join(Paths.pimHomeDir(), "sessions.json");
59
+ }
60
+
61
+ public async of(sessionId: string): Promise<SessionEntry> {
62
+ return (await this.sessions()).get(sessionId) ?? NONE;
63
+ }
64
+
65
+ /** One read for a whole listing; `of` per row is one read per row. */
66
+ public async sessions(): Promise<ReadonlyMap<string, SessionEntry>> {
67
+ return (await this.read()).sessions;
68
+ }
69
+
70
+ /** The pinned directories in display order; a listing wants both halves, and this is one read for them. */
71
+ public async pinning(): Promise<Pinning> {
72
+ const loaded = await this.read();
73
+ return { projects: loaded.projects, order: ordered(loaded) };
74
+ }
75
+
76
+ /** The pinned directories in display order. */
77
+ public async pins(): Promise<readonly string[]> {
78
+ return ordered(await this.read());
79
+ }
80
+
81
+ public setArchived(sessionId: string, archived: boolean): Promise<void> {
82
+ return this.mutate((loaded) =>
83
+ put(loaded.sessions, sessionId, { archived })
84
+ );
85
+ }
86
+
87
+ public setUnread(sessionId: string, unread: boolean): Promise<void> {
88
+ return this.mutate((loaded) => put(loaded.sessions, sessionId, { unread }));
89
+ }
90
+
91
+ /** A new pin goes to the top, where you have just put it; an old one leaves the order with the flag. */
92
+ public setPinned(cwd: string, pinned: boolean): Promise<void> {
93
+ return this.mutate((loaded) => {
94
+ const at = loaded.pins.indexOf(cwd);
95
+ if (at !== -1) {
96
+ loaded.pins.splice(at, 1);
97
+ }
98
+ if (pinned) {
99
+ loaded.pins.unshift(cwd);
100
+ }
101
+ return put(loaded.projects, cwd, { pinned });
102
+ });
103
+ }
104
+
105
+ /**
106
+ * Folds a project's group, or unfolds it. Kept beside the pin rather than
107
+ * in the browser, so the fold a phone made is the fold a desktop opens to.
108
+ */
109
+ public setExpanded(cwd: string, expanded: boolean): Promise<void> {
110
+ return this.mutate((loaded) => put(loaded.projects, cwd, { expanded }));
111
+ }
112
+
113
+ /** Names the project for the sidebar; `null` puts it back to its base name. */
114
+ public setLabel(cwd: string, label: string | null): Promise<void> {
115
+ return this.mutate((loaded) =>
116
+ put(loaded.projects, cwd, { label: label ?? "" })
117
+ );
118
+ }
119
+
120
+ /** Takes the whole order rather than a move, so two surfaces settle on the last one written. */
121
+ public setPinOrder(order: readonly string[]): Promise<void> {
122
+ return this.mutate((loaded) => {
123
+ loaded.pins = [...new Set(order)].filter(
124
+ (cwd) => loaded.projects.get(cwd)?.pinned === true
125
+ );
126
+ return true;
127
+ });
128
+ }
129
+
130
+ /** Drops the sessions that are gone; a pin outlives every session of its project. */
131
+ public prune(alive: ReadonlySet<string>): Promise<void> {
132
+ return this.mutate((loaded) => {
133
+ let dropped = false;
134
+ for (const sessionId of loaded.sessions.keys()) {
135
+ if (!alive.has(sessionId)) {
136
+ loaded.sessions.delete(sessionId);
137
+ dropped = true;
138
+ }
139
+ }
140
+ return dropped;
141
+ });
142
+ }
143
+
144
+ /** Settles the writes taken so far, for a clean stop. */
145
+ public async flush(): Promise<void> {
146
+ await this.writes.run(async () => undefined);
147
+ }
148
+
149
+ private async read(): Promise<Loaded> {
150
+ const raw = Json.asRecord(
151
+ await Fs.readJsonOr<unknown>(this.file, undefined)
152
+ );
153
+ if (raw?.version !== 1) {
154
+ return { sessions: new Map(), projects: new Map(), pins: [] };
155
+ }
156
+ return {
157
+ sessions: parseAll(raw.sessions, parseSession),
158
+ projects: parseAll(raw.projects, parseProject),
159
+ pins: Array.isArray(raw.pins)
160
+ ? raw.pins.filter((cwd) => typeof cwd === "string")
161
+ : [],
162
+ };
163
+ }
164
+
165
+ /** Re-reads under the lock, so a write by another process survives this one. */
166
+ private async mutate(update: (loaded: Loaded) => boolean): Promise<void> {
167
+ await this.writes.run(async () => {
168
+ const loaded = await this.read();
169
+ if (!update(loaded)) {
170
+ return;
171
+ }
172
+ await Paths.ensurePimHome();
173
+ await Fs.writeJson(this.file, {
174
+ version: 1,
175
+ sessions: Object.fromEntries(loaded.sessions),
176
+ projects: Object.fromEntries(loaded.projects),
177
+ pins: loaded.pins,
178
+ } satisfies Stored);
179
+ });
180
+ }
181
+ }
182
+
183
+ /**
184
+ * The pinned directories in display order: the ones the stored order names,
185
+ * then any pin it has not heard of, by path. A duplicate in the order is
186
+ * taken once, at the first place it appears.
187
+ */
188
+ function ordered({ projects, pins }: Loaded): readonly string[] {
189
+ const unplaced = new Set(
190
+ [...projects]
191
+ .filter(([, entry]) => entry.pinned === true)
192
+ .map(([cwd]) => cwd)
193
+ );
194
+ const listed = pins.filter((cwd) => unplaced.delete(cwd));
195
+ return [...listed, ...[...unplaced].sort()];
196
+ }
197
+
198
+ function put<T extends object>(
199
+ into: Map<string, T>,
200
+ key: string,
201
+ patch: T
202
+ ): boolean {
203
+ const next = onlySet({ ...into.get(key), ...patch });
204
+ if (Object.keys(next).length === 0) {
205
+ into.delete(key);
206
+ } else {
207
+ into.set(key, next);
208
+ }
209
+ return true;
210
+ }
211
+
212
+ /** Every field is opt-in, so a false flag or an empty name is the same as an absent one. */
213
+ function onlySet<T extends object>(entry: T): T {
214
+ return Object.fromEntries(
215
+ Object.entries(entry).filter(
216
+ ([, value]) =>
217
+ value === true || (typeof value === "string" && value !== "")
218
+ )
219
+ ) as T;
220
+ }
221
+
222
+ function parseAll<T>(
223
+ value: unknown,
224
+ parse: (value: unknown) => T | undefined
225
+ ): Map<string, T> {
226
+ const parsed = new Map<string, T>();
227
+ for (const [key, entry] of Object.entries(Json.asRecord(value) ?? {})) {
228
+ const valid = parse(entry);
229
+ if (valid !== undefined) {
230
+ parsed.set(key, valid);
231
+ }
232
+ }
233
+ return parsed;
234
+ }
235
+
236
+ function parseSession(value: unknown): SessionEntry | undefined {
237
+ const raw = Json.asRecord(value);
238
+ if (raw === undefined || !isFlag(raw.archived) || !isFlag(raw.unread)) {
239
+ return undefined;
240
+ }
241
+ return onlySet({ archived: raw.archived, unread: raw.unread });
242
+ }
243
+
244
+ function parseProject(value: unknown): ProjectEntry | undefined {
245
+ const raw = Json.asRecord(value);
246
+ if (
247
+ raw === undefined ||
248
+ !isFlag(raw.pinned) ||
249
+ !isFlag(raw.expanded) ||
250
+ !isName(raw.label)
251
+ ) {
252
+ return undefined;
253
+ }
254
+ return onlySet({
255
+ pinned: raw.pinned,
256
+ expanded: raw.expanded,
257
+ label: raw.label,
258
+ });
259
+ }
260
+
261
+ function isFlag(value: unknown): value is boolean | undefined {
262
+ return value === undefined || typeof value === "boolean";
263
+ }
264
+
265
+ function isName(value: unknown): value is string | undefined {
266
+ return value === undefined || typeof value === "string";
267
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Twice the width a sidebar row can show, so a name is never silently cut at the
3
+ * length it happens to render at, and a pasted essay still cannot become one.
4
+ */
5
+ const LIMIT = 80;
6
+
7
+ /** What pi should store for this name: whitespace collapsed, controls gone, `""` to clear. */
8
+ function normalise(name: string | null | undefined): string {
9
+ const clean = (name ?? "")
10
+ .replace(/\p{Cc}/gu, " ")
11
+ .replace(/\s+/gu, " ")
12
+ .trim();
13
+ // By code point: a cap that splits a surrogate pair stores half a character.
14
+ return [...clean].slice(0, LIMIT).join("").trim();
15
+ }
16
+
17
+ /** The one spelling of a session name pim writes into pi's `session_info`. */
18
+ export const SessionName = { normalise };