hive-am 1.0.0 → 1.2.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 (268) hide show
  1. package/CHANGELOG.md +114 -1
  2. package/README.md +34 -4
  3. package/package.json +13 -2
  4. package/server/dist/api.js +42 -677
  5. package/server/dist/connections/addressing.js +87 -0
  6. package/server/dist/connections/channel-skill.js +62 -0
  7. package/server/dist/connections/fake.js +1 -0
  8. package/server/dist/connections/files.js +45 -1
  9. package/server/dist/connections/format.js +9 -1
  10. package/server/dist/connections/manager.js +60 -3
  11. package/server/dist/connections/outbound.js +2 -1
  12. package/server/dist/connections/prompt.js +12 -3
  13. package/server/dist/connections/rate.js +20 -0
  14. package/server/dist/connections/router.js +55 -22
  15. package/server/dist/connections/secrets.js +52 -0
  16. package/server/dist/connections/store.js +6 -43
  17. package/server/dist/connections/telegram.js +105 -14
  18. package/server/dist/cron.js +138 -0
  19. package/server/dist/db/json.js +16 -0
  20. package/server/dist/db/migrations.js +199 -0
  21. package/server/dist/db.js +98 -196
  22. package/server/dist/errors.js +11 -0
  23. package/server/dist/git/browse.js +99 -0
  24. package/server/dist/git/ops.js +136 -19
  25. package/server/dist/git/repo.js +2 -1
  26. package/server/dist/history/claude.js +33 -20
  27. package/server/dist/history/index.js +17 -9
  28. package/server/dist/history/kiro.js +17 -12
  29. package/server/dist/history/lines.js +12 -0
  30. package/server/dist/history/opencode.js +18 -1
  31. package/server/dist/http/body.js +46 -0
  32. package/server/dist/http/errors.js +21 -0
  33. package/server/dist/http/guards.js +44 -0
  34. package/server/dist/http/router.js +24 -0
  35. package/server/dist/http/routes/agents.js +103 -0
  36. package/server/dist/http/routes/channel.js +16 -0
  37. package/server/dist/http/routes/colonies.js +9 -0
  38. package/server/dist/http/routes/connections.js +36 -0
  39. package/server/dist/http/routes/fs.js +22 -0
  40. package/server/dist/http/routes/git.js +90 -0
  41. package/server/dist/http/routes/meta.js +11 -0
  42. package/server/dist/http/routes/notebook.js +34 -0
  43. package/server/dist/http/routes/orchestration.js +32 -0
  44. package/server/dist/http/routes/resources.js +68 -0
  45. package/server/dist/http/routes/schedules.js +40 -0
  46. package/server/dist/http/routes/skills.js +47 -0
  47. package/server/dist/http/routes/terminals.js +18 -0
  48. package/server/dist/http/routes/types.js +36 -0
  49. package/server/dist/http/validate.js +65 -0
  50. package/server/dist/index.js +74 -4
  51. package/server/dist/instructions.js +23 -11
  52. package/server/dist/objects/agent.js +49 -0
  53. package/server/dist/objects/database.js +451 -0
  54. package/server/dist/objects/db-history.js +79 -0
  55. package/server/dist/objects/docker-info.js +75 -0
  56. package/server/dist/objects/docker.js +285 -0
  57. package/server/dist/objects/driver-props.js +196 -0
  58. package/server/dist/objects/exec.js +16 -0
  59. package/server/dist/objects/http-history.js +44 -0
  60. package/server/dist/objects/http-script.js +644 -0
  61. package/server/dist/objects/http.js +735 -0
  62. package/server/dist/objects/index.js +445 -0
  63. package/server/dist/objects/model.js +250 -0
  64. package/server/dist/objects/runner.js +211 -0
  65. package/server/dist/objects/stats.js +106 -0
  66. package/server/dist/objects/store.js +115 -0
  67. package/server/dist/objects/supervisor.js +111 -0
  68. package/server/dist/objects/templates.js +67 -0
  69. package/server/dist/providers/claude.js +5 -13
  70. package/server/dist/providers/index.js +7 -1
  71. package/server/dist/providers/kiro.js +4 -5
  72. package/server/dist/providers/opencode-server.js +164 -0
  73. package/server/dist/providers/opencode.js +60 -37
  74. package/server/dist/providers/preamble.js +2 -0
  75. package/server/dist/providers/spawn.js +31 -3
  76. package/server/dist/runtime.js +10 -7
  77. package/server/dist/schedules.js +222 -0
  78. package/server/dist/seed.js +15 -11
  79. package/server/dist/services/agents.js +90 -0
  80. package/server/dist/services/colonies.js +38 -0
  81. package/server/dist/services/connections.js +76 -0
  82. package/server/dist/services/notebook.js +22 -0
  83. package/server/dist/services/providers.js +27 -0
  84. package/server/dist/services/schedules.js +18 -0
  85. package/server/dist/sessions-summary.js +51 -0
  86. package/server/dist/skills/defaults.js +120 -0
  87. package/server/dist/skills/notebook.js +3 -8
  88. package/server/dist/terminals.js +177 -0
  89. package/server/dist/wake.js +149 -0
  90. package/server/dist/watch.js +201 -0
  91. package/server/mcp/dispatch.mjs +169 -1
  92. package/web/.next/BUILD_ID +1 -1
  93. package/web/.next/app-build-manifest.json +145 -85
  94. package/web/.next/app-path-routes-manifest.json +5 -2
  95. package/web/.next/build-manifest.json +14 -14
  96. package/web/.next/prerender-manifest.json +85 -37
  97. package/web/.next/react-loadable-manifest.json +26 -1
  98. package/web/.next/required-server-files.json +13 -1
  99. package/web/.next/routes-manifest.json +32 -0
  100. package/web/.next/server/app/_not-found/page.js +2 -2
  101. package/web/.next/server/app/_not-found/page.js.nft.json +1 -1
  102. package/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  103. package/web/.next/server/app/_not-found.html +1 -1
  104. package/web/.next/server/app/_not-found.rsc +12 -11
  105. package/web/.next/server/app/agents/[id]/page.js +4 -4
  106. package/web/.next/server/app/agents/[id]/page.js.nft.json +1 -1
  107. package/web/.next/server/app/agents/[id]/page_client-reference-manifest.js +1 -1
  108. package/web/.next/server/app/agents/page.js +2 -2
  109. package/web/.next/server/app/agents/page.js.nft.json +1 -1
  110. package/web/.next/server/app/agents/page_client-reference-manifest.js +1 -1
  111. package/web/.next/server/app/agents.html +1 -1
  112. package/web/.next/server/app/agents.rsc +14 -13
  113. package/web/.next/server/app/connections/page.js +2 -2
  114. package/web/.next/server/app/connections/page.js.nft.json +1 -1
  115. package/web/.next/server/app/connections/page_client-reference-manifest.js +1 -1
  116. package/web/.next/server/app/connections.html +1 -1
  117. package/web/.next/server/app/connections.rsc +14 -13
  118. package/web/.next/server/app/index.html +1 -1
  119. package/web/.next/server/app/index.rsc +14 -13
  120. package/web/.next/server/app/page.js +2 -2
  121. package/web/.next/server/app/page.js.nft.json +1 -1
  122. package/web/.next/server/app/page_client-reference-manifest.js +1 -1
  123. package/web/.next/server/app/relations/page.js +2 -2
  124. package/web/.next/server/app/relations/page.js.nft.json +1 -1
  125. package/web/.next/server/app/relations/page_client-reference-manifest.js +1 -1
  126. package/web/.next/server/app/relations.html +1 -1
  127. package/web/.next/server/app/relations.rsc +14 -13
  128. package/web/.next/server/app/resources/[id]/page.js +2 -0
  129. package/web/.next/server/app/resources/[id]/page.js.nft.json +1 -0
  130. package/web/.next/server/app/resources/[id]/page_client-reference-manifest.js +1 -0
  131. package/web/.next/server/app/resources/page.js +2 -0
  132. package/web/.next/server/app/resources/page.js.nft.json +1 -0
  133. package/web/.next/server/app/resources/page_client-reference-manifest.js +1 -0
  134. package/web/.next/server/app/resources.html +1 -0
  135. package/web/.next/server/app/resources.meta +15 -0
  136. package/web/.next/server/app/resources.rsc +25 -0
  137. package/web/.next/server/app/schedules/page.js +2 -0
  138. package/web/.next/server/app/schedules/page.js.nft.json +1 -0
  139. package/web/.next/server/app/schedules/page_client-reference-manifest.js +1 -0
  140. package/web/.next/server/app/schedules.html +1 -0
  141. package/web/.next/server/app/schedules.meta +15 -0
  142. package/web/.next/server/app/schedules.rsc +25 -0
  143. package/web/.next/server/app/sessions/page.js +2 -2
  144. package/web/.next/server/app/sessions/page.js.nft.json +1 -1
  145. package/web/.next/server/app/sessions/page_client-reference-manifest.js +1 -1
  146. package/web/.next/server/app/sessions.html +1 -1
  147. package/web/.next/server/app/sessions.rsc +14 -13
  148. package/web/.next/server/app/skills/page.js +2 -2
  149. package/web/.next/server/app/skills/page.js.nft.json +1 -1
  150. package/web/.next/server/app/skills/page_client-reference-manifest.js +1 -1
  151. package/web/.next/server/app/skills.html +1 -1
  152. package/web/.next/server/app/skills.rsc +14 -13
  153. package/web/.next/server/app/types/page.js +2 -2
  154. package/web/.next/server/app/types/page.js.nft.json +1 -1
  155. package/web/.next/server/app/types/page_client-reference-manifest.js +1 -1
  156. package/web/.next/server/app/types.html +1 -1
  157. package/web/.next/server/app/types.rsc +14 -13
  158. package/web/.next/server/app-paths-manifest.json +5 -2
  159. package/web/.next/server/chunks/143.js +1 -1
  160. package/web/.next/server/chunks/274.js +1 -0
  161. package/web/.next/server/chunks/306.js +1 -0
  162. package/web/.next/server/chunks/423.js +1 -0
  163. package/web/.next/server/chunks/505.js +6 -0
  164. package/web/.next/server/chunks/555.js +1 -0
  165. package/web/.next/server/chunks/612.js +1 -0
  166. package/web/.next/server/chunks/638.js +1 -0
  167. package/web/.next/server/chunks/692.js +18 -0
  168. package/web/.next/server/chunks/716.js +2 -0
  169. package/web/.next/server/chunks/790.js +11 -0
  170. package/web/.next/server/chunks/802.js +1 -0
  171. package/web/.next/server/chunks/890.js +1 -1
  172. package/web/.next/server/chunks/919.js +1 -0
  173. package/web/.next/server/chunks/950.js +6 -0
  174. package/web/.next/server/middleware-build-manifest.js +1 -1
  175. package/web/.next/server/middleware-react-loadable-manifest.js +1 -1
  176. package/web/.next/server/next-font-manifest.js +1 -1
  177. package/web/.next/server/next-font-manifest.json +1 -1
  178. package/web/.next/server/pages/404.html +1 -1
  179. package/web/.next/server/pages/500.html +1 -1
  180. package/web/.next/server/server-reference-manifest.json +1 -1
  181. package/web/.next/static/chunks/0937d497-114a2a3de515565e.js +1 -0
  182. package/web/.next/static/chunks/176-aeaf907a6ea5627d.js +1 -0
  183. package/web/.next/static/chunks/291-efae53ea5f7ee92d.js +1 -0
  184. package/web/.next/static/chunks/2b9ff8ee-969e65cc354b2150.js +1 -0
  185. package/web/.next/static/chunks/30-9adcbe92b15a6815.js +1 -0
  186. package/web/.next/static/chunks/316-26da9cc233ba7557.js +1 -0
  187. package/web/.next/static/chunks/350-56599dc3bf06066d.js +1 -0
  188. package/web/.next/static/chunks/486-13e08542f3a1cbac.js +1 -0
  189. package/web/.next/static/chunks/491-6b4e6201f5110dff.js +1 -0
  190. package/web/.next/static/chunks/517-ccaf8cc5c733cbd8.js +1 -0
  191. package/web/.next/static/chunks/545.bfbd92b7632c09de.js +1 -0
  192. package/web/.next/static/chunks/{805-d946ebedb7e85f73.js → 562-7c2e33d838e869bf.js} +1 -1
  193. package/web/.next/static/chunks/581-3ad5c4343dad3f50.js +1 -0
  194. package/web/.next/static/chunks/586-f7017d585a84738d.js +1 -0
  195. package/web/.next/static/chunks/657.b4c39e676c3e20dc.js +1 -0
  196. package/web/.next/static/chunks/701.6abcaf5e60463eef.js +6 -0
  197. package/web/.next/static/chunks/705-a16e1fe09be1ac7f.js +1 -0
  198. package/web/.next/static/chunks/706-fa2be0f10ffebe6a.js +1 -0
  199. package/web/.next/static/chunks/837-33c7480524f48577.js +1 -0
  200. package/web/.next/static/chunks/839-23c5508622fae942.js +1 -0
  201. package/web/.next/static/chunks/844-fe55292e0701f436.js +1 -0
  202. package/web/.next/static/chunks/89-b194fcffe5120c44.js +1 -0
  203. package/web/.next/static/chunks/97.594543c2196de524.js +1 -0
  204. package/web/.next/static/chunks/app/_not-found/page-238a7b80bda6b4ae.js +1 -0
  205. package/web/.next/static/chunks/app/agents/[id]/page-4af6c246c4ca7a97.js +1 -0
  206. package/web/.next/static/chunks/app/agents/page-f4c4c80f955f3967.js +1 -0
  207. package/web/.next/static/chunks/app/connections/page-0936beaf02d863c6.js +1 -0
  208. package/web/.next/static/chunks/app/layout-87ebb70bd9cb8443.js +1 -0
  209. package/web/.next/static/chunks/app/page-a7f292117b7d76bf.js +1 -0
  210. package/web/.next/static/chunks/app/relations/page-fba25393c8688714.js +1 -0
  211. package/web/.next/static/chunks/app/resources/[id]/page-d110d89bb8b5fca7.js +1 -0
  212. package/web/.next/static/chunks/app/resources/page-bbd0b450d3f3131d.js +1 -0
  213. package/web/.next/static/chunks/app/schedules/page-a1124397fc760413.js +1 -0
  214. package/web/.next/static/chunks/app/sessions/page-0e322811b0cde724.js +1 -0
  215. package/web/.next/static/chunks/app/skills/page-22ea0ef89d9f7a13.js +1 -0
  216. package/web/.next/static/chunks/app/types/page-18c4e1db2724d41d.js +1 -0
  217. package/web/.next/static/chunks/fcafe1ac.366e4950d3b0d4b7.js +18 -0
  218. package/web/.next/static/chunks/framework-bbac72adbbe2d328.js +1 -0
  219. package/web/.next/static/chunks/main-app-6086068e5fb5eacd.js +1 -0
  220. package/web/.next/static/chunks/main-c15f3fee674e0303.js +1 -0
  221. package/web/.next/static/chunks/pages/_app-d1670a2c6ce392fc.js +1 -0
  222. package/web/.next/static/chunks/pages/_error-95aac014d202fa7a.js +1 -0
  223. package/web/.next/static/chunks/webpack-ef515e41a83a16ac.js +1 -0
  224. package/web/.next/static/css/945795da867f5250.css +1 -0
  225. package/web/.next/static/css/dd4e32c285f75776.css +1 -0
  226. package/web/.next/static/p4S3i9kvO359GW91DQiHu/_buildManifest.js +1 -0
  227. package/web/.next/trace +29 -23
  228. package/web/.next/types/app/resources/[id]/page.ts +84 -0
  229. package/web/.next/types/app/resources/page.ts +84 -0
  230. package/web/.next/types/app/schedules/page.ts +84 -0
  231. package/web/.next/types/routes.d.ts +7 -2
  232. package/web/.next/types/validator.ts +27 -0
  233. package/web/next.config.mjs +4 -0
  234. package/web/.next/server/chunks/149.js +0 -11
  235. package/web/.next/server/chunks/247.js +0 -1
  236. package/web/.next/server/chunks/851.js +0 -1
  237. package/web/.next/server/chunks/932.js +0 -2
  238. package/web/.next/static/84BpRSahg2BTwk3mX6AG8/_buildManifest.js +0 -1
  239. package/web/.next/static/chunks/0937d497-44abb0f9355ace92.js +0 -1
  240. package/web/.next/static/chunks/2b9ff8ee-c3b61ac1e608c57d.js +0 -1
  241. package/web/.next/static/chunks/351-65da8c1a6e25f93b.js +0 -1
  242. package/web/.next/static/chunks/463-6ae35cdefdc59169.js +0 -1
  243. package/web/.next/static/chunks/493-92266798be872b8f.js +0 -1
  244. package/web/.next/static/chunks/517-eb26c72f6059aa26.js +0 -1
  245. package/web/.next/static/chunks/657.f1e03b7b65eba1d9.js +0 -1
  246. package/web/.next/static/chunks/687-3a4205f51be38602.js +0 -1
  247. package/web/.next/static/chunks/837-fd7623601dc78ed8.js +0 -1
  248. package/web/.next/static/chunks/850-318f32484989dd59.js +0 -1
  249. package/web/.next/static/chunks/89-ace2e1aa63babfdf.js +0 -1
  250. package/web/.next/static/chunks/951-e250b541b67da7ce.js +0 -1
  251. package/web/.next/static/chunks/app/_not-found/page-bbd54d7b9a55d7ae.js +0 -1
  252. package/web/.next/static/chunks/app/agents/[id]/page-5eaf094fec246f1b.js +0 -1
  253. package/web/.next/static/chunks/app/agents/page-e9b17f1c3439ef98.js +0 -1
  254. package/web/.next/static/chunks/app/connections/page-c6afd90abd82d47a.js +0 -1
  255. package/web/.next/static/chunks/app/layout-c415d5a847cd94b3.js +0 -1
  256. package/web/.next/static/chunks/app/page-6fc87fc5a128a204.js +0 -1
  257. package/web/.next/static/chunks/app/relations/page-0355a93a8eb67989.js +0 -1
  258. package/web/.next/static/chunks/app/sessions/page-beae9a74a07d69e0.js +0 -1
  259. package/web/.next/static/chunks/app/skills/page-4f839ac913e26d32.js +0 -1
  260. package/web/.next/static/chunks/app/types/page-4f6b99af3232f861.js +0 -1
  261. package/web/.next/static/chunks/framework-0e68884e76d9a8d3.js +0 -1
  262. package/web/.next/static/chunks/main-app-d6f73ccd41f325c5.js +0 -1
  263. package/web/.next/static/chunks/main-cd22509db15764e2.js +0 -1
  264. package/web/.next/static/chunks/pages/_app-0f75d689c738a6fc.js +0 -1
  265. package/web/.next/static/chunks/pages/_error-d481f6d49fb4455d.js +0 -1
  266. package/web/.next/static/chunks/webpack-d40de72e52f65f79.js +0 -1
  267. package/web/.next/static/css/abe1a6216491f944.css +0 -1
  268. /package/web/.next/static/{84BpRSahg2BTwk3mX6AG8 → p4S3i9kvO359GW91DQiHu}/_ssgManifest.js +0 -0
@@ -0,0 +1,11 @@
1
+ export class DomainError extends Error {
2
+ kind;
3
+ code;
4
+ constructor(message, kind = 'invalid', code) {
5
+ super(message);
6
+ this.kind = kind;
7
+ this.code = code;
8
+ }
9
+ }
10
+ /** The HTTP status a kind maps to. */
11
+ export const STATUS_OF_KIND = { invalid: 400, forbidden: 403, not_found: 404, conflict: 409, unavailable: 503 };
@@ -0,0 +1,99 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { filesFromDiffTree, repoOf, resolveRef, run } from './ops.js';
3
+ import { gitEnv, imageType, PathError, safePath } from './repo.js';
4
+ const MAX_REFS = 1000;
5
+ export async function gitRefs(cwd) {
6
+ const { root } = await repoOf(cwd);
7
+ const [heads, tags] = await Promise.all([
8
+ run(root, ['for-each-ref', '--sort=-committerdate', `--count=${MAX_REFS}`, '--format=%(refname)%1f%(refname:short)%1f%(objectname)%1f%(committerdate:iso-strict)%1f%(contents:subject)%1f%(HEAD)', 'refs/heads', 'refs/remotes']),
9
+ // For an annotated tag %(*objectname) is its commit; for a lightweight one %(objectname) already is.
10
+ run(root, ['for-each-ref', '--sort=-creatordate', `--count=${MAX_REFS}`, '--format=%(refname:strip=2)%1f%(*objectname)%1f%(objectname)%1f%(creatordate:iso-strict)%1f%(contents:subject)', 'refs/tags']),
11
+ ]);
12
+ const res = { current: null, branches: [], remotes: [], tags: [] };
13
+ for (const line of heads.out.split('\n').filter(Boolean)) {
14
+ const [ref, name, sha, date, subject, head] = line.split('\x1f');
15
+ if (ref.startsWith('refs/heads/')) {
16
+ res.branches.push({ name, sha, date, subject });
17
+ if (head === '*')
18
+ res.current = name;
19
+ }
20
+ else if (!ref.endsWith('/HEAD'))
21
+ res.remotes.push({ name, sha, date, subject });
22
+ }
23
+ for (const line of tags.out.split('\n').filter(Boolean)) {
24
+ const [name, peeled, direct, date, subject] = line.split('\x1f');
25
+ res.tags.push({ name, sha: peeled || direct, date, subject });
26
+ }
27
+ return res;
28
+ }
29
+ const MAX_COMPARE_FILES = 500, MAX_COMPARE_COMMITS = 100;
30
+ /** What changed going from `base` to `head`: the files, and the commits in between. */
31
+ export async function gitCompare(cwd, base, head) {
32
+ const { root } = await repoOf(cwd);
33
+ const [b, h] = [await resolveRef(root, base), await resolveRef(root, head)];
34
+ const [names, nums, ahead, behind, log] = await Promise.all([
35
+ run(root, ['diff-tree', '-r', '-M', '--name-status', '-z', '--no-commit-id', b, h], { raw: true }),
36
+ run(root, ['diff-tree', '-r', '-M', '--histogram', '--numstat', '-z', '--no-commit-id', b, h], { raw: true }),
37
+ run(root, ['rev-list', '--count', `${b}..${h}`]),
38
+ run(root, ['rev-list', '--count', `${h}..${b}`]),
39
+ run(root, ['log', `--max-count=${MAX_COMPARE_COMMITS + 1}`, '--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1e', `${b}..${h}`]),
40
+ ]);
41
+ const files = filesFromDiffTree(names.out, nums.out);
42
+ const commits = log.out.split('\x1e').map((r) => r.trim()).filter(Boolean).map((r) => { const [sha, short, author, date, subject] = r.split('\x1f'); return { sha, short, author, date, subject }; });
43
+ return {
44
+ base: { ref: base, sha: b }, head: { ref: head, sha: h }, ahead: Number(ahead.out) || 0, behind: Number(behind.out) || 0,
45
+ commits: commits.slice(0, MAX_COMPARE_COMMITS), commitsTruncated: commits.length > MAX_COMPARE_COMMITS,
46
+ files: files.slice(0, MAX_COMPARE_FILES), truncated: files.length > MAX_COMPARE_FILES,
47
+ };
48
+ }
49
+ export async function gitCompareDiff(cwd, base, head, rel, oldRel) {
50
+ const { root } = await repoOf(cwd);
51
+ const [b, h] = [await resolveRef(root, base), await resolveRef(root, head)];
52
+ await safePath(cwd, root, rel, false);
53
+ if (oldRel)
54
+ await safePath(cwd, root, oldRel, false);
55
+ const { out } = await run(root, ['diff', '--histogram', '--no-color', '--no-ext-diff', '-M', '-U3', b, h, '--', ...(oldRel ? [oldRel, rel] : [rel])]);
56
+ const MAX = 600_000;
57
+ return { path: rel, diff: out.length > MAX ? out.slice(0, MAX) : out, truncated: out.length > MAX, binary: /^Binary files .* differ$/m.test(out) || /^GIT binary patch/m.test(out) };
58
+ }
59
+ const MAX_HITS = 300;
60
+ /** Lines of the working files (tracked and new, never ignored ones) that contain `q`. */
61
+ export async function gitGrep(cwd, q, caseSensitive = false) {
62
+ const { root, scope } = await repoOf(cwd);
63
+ const text = q.trim().slice(0, 200);
64
+ if (text.length < 2)
65
+ return { hits: [], truncated: false };
66
+ const { out } = await run(root, ['grep', '-n', '-I', '--null', '--fixed-strings', '--no-color', '--untracked', '--exclude-standard', ...(caseSensitive ? [] : ['-i']), '-e', text, '--', scope || '.'], { okCodes: [1], raw: true });
67
+ const hits = [];
68
+ let truncated = false;
69
+ for (const l of out.split('\n')) {
70
+ if (!l)
71
+ continue;
72
+ if (hits.length >= MAX_HITS) {
73
+ truncated = true;
74
+ break;
75
+ }
76
+ // With --null both the file name and the line number are followed by a NUL: `path NUL line NUL text`.
77
+ const [path, line, ...text] = l.split('\0');
78
+ if (!path || text.length === 0)
79
+ continue;
80
+ hits.push({ path, line: Number(line) || 0, text: text.join('\0').slice(0, 300) });
81
+ }
82
+ return { hits, truncated };
83
+ }
84
+ /* ------------------------------------------------------------------ pictures at a version */
85
+ const MAX_IMAGE = 20 * 1024 * 1024;
86
+ /** The bytes of a picture as it was at a commit, served only as an inert image (see the route). */
87
+ export async function gitImageAt(cwd, ref, rel) {
88
+ const { root } = await repoOf(cwd);
89
+ const type = imageType(rel);
90
+ if (!type)
91
+ throw new PathError('Not an image');
92
+ const sha = await resolveRef(root, ref);
93
+ if (!rel || rel.includes('\0') || rel.startsWith('/') || rel.split('/').includes('..'))
94
+ throw new PathError('Invalid path');
95
+ const bytes = await new Promise((resolve, reject) => {
96
+ execFile('git', ['cat-file', 'blob', `${sha}:${rel}`], { cwd: root, encoding: 'buffer', maxBuffer: MAX_IMAGE, env: gitEnv(), timeout: 30_000 }, (err, stdout) => (err ? reject(new PathError('Picture not found at that version')) : resolve(stdout)));
97
+ });
98
+ return { bytes, type };
99
+ }
@@ -1,13 +1,14 @@
1
1
  import { execFile } from 'node:child_process';
2
2
  import { writeFile } from 'node:fs/promises';
3
3
  import { findRepo, gitEnv, hasHead, isPathError, parseNumstat, PathError, repoState, safePath } from './repo.js';
4
+ import { DomainError } from '../errors.js';
4
5
  /**
5
6
  * Git actions for the explorer: history (reads) and commit / pull / push / fetch / branches (writes).
6
7
  * Same rules as git.ts: `execFile` with no shell, user input only ever as an argument value (messages after -m,
7
8
  * paths after `--`, branch names validated), never `--force`, and no command that can prompt for credentials.
8
9
  */
9
10
  export { isPathError };
10
- export class GitOpError extends Error {
11
+ export class GitOpError extends DomainError {
11
12
  }
12
13
  const LOCAL_TIMEOUT = 30_000;
13
14
  const NET_TIMEOUT = 120_000;
@@ -54,41 +55,74 @@ export async function exclusive(root, fn) {
54
55
  locks.delete(root);
55
56
  }
56
57
  }
57
- const SHA = /^[0-9a-f]{4,40}$/i;
58
+ export const SHA = /^[0-9a-f]{4,40}$/i;
58
59
  export async function validBranch(root, name) {
59
60
  if (!name || name.startsWith('-') || name.includes('\0'))
60
61
  throw new GitOpError('Invalid branch name');
61
62
  await run(root, ['check-ref-format', '--branch', name]).catch(() => { throw new GitOpError(`"${name}" is not a valid branch name`); });
62
63
  }
63
- export async function gitLog(cwd, skip = 0) {
64
+ export async function gitLog(cwd, skip = 0, f = {}) {
64
65
  const { root, scope } = await repoOf(cwd);
65
66
  if (!(await hasHead(root)))
66
67
  return { commits: [], hasMore: false };
67
- const { out } = await run(root, ['log', `--skip=${Math.max(0, Math.floor(skip))}`, `--max-count=${LOG_PAGE + 1}`, '--decorate=short',
68
- '--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%D%x1f%P%x1e', '--', scope || '.']);
68
+ const q = (f.q ?? '').trim().slice(0, 200);
69
+ const args = ['log', `--skip=${Math.max(0, Math.floor(skip))}`, `--max-count=${LOG_PAGE + 1}`, '--decorate=short', '--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%D%x1f%P%x1e'];
70
+ if (f.ref === 'all')
71
+ args.push('--all');
72
+ else if (f.ref && f.ref !== 'HEAD')
73
+ args.push(await resolveRef(root, f.ref));
74
+ if (q) {
75
+ // A hash (or the start of one) finds that commit directly.
76
+ if (/^[0-9a-f]{7,40}$/i.test(q) && (!f.by || f.by === 'message')) {
77
+ const hit = (await run(root, ['rev-parse', '--verify', '--quiet', `${q}^{commit}`], { okCodes: [1] })).out.trim();
78
+ if (SHA.test(hit))
79
+ return gitLogOne(root, hit);
80
+ }
81
+ if (f.by === 'author')
82
+ args.push('--regexp-ignore-case', '--fixed-strings', `--author=${q}`);
83
+ else if (f.by === 'content')
84
+ args.push('--regexp-ignore-case', '-S', q);
85
+ else
86
+ args.push('--regexp-ignore-case', '--fixed-strings', `--grep=${q}`);
87
+ }
88
+ let spec = scope || '.';
89
+ if (f.path) {
90
+ if (f.path.includes('\0') || f.path.startsWith('/') || f.path.split('/').includes('..'))
91
+ throw new PathError('Invalid path');
92
+ args.push('--follow');
93
+ spec = f.path;
94
+ }
95
+ const { out } = await run(root, [...args, '--', spec]);
96
+ return { ...parseLog(out) };
97
+ }
98
+ function parseLog(out) {
69
99
  const all = out.split('\x1e').map((r) => r.trim()).filter(Boolean).map((r) => {
70
100
  const [sha, short, author, date, subject, refs, parents] = r.split('\x1f');
71
101
  return { sha, short, author, date, subject, refs: (refs ?? '').split(', ').map((x) => x.replace(/^HEAD -> /, '').trim()).filter((x) => x && x !== 'HEAD'), merge: (parents ?? '').trim().split(' ').filter(Boolean).length > 1 };
72
102
  });
73
103
  return { commits: all.slice(0, LOG_PAGE), hasMore: all.length > LOG_PAGE };
74
104
  }
105
+ async function gitLogOne(root, sha) {
106
+ const { out } = await run(root, ['log', '-1', '--decorate=short', '--format=%H%x1f%h%x1f%an%x1f%aI%x1f%s%x1f%D%x1f%P%x1e', sha]);
107
+ return { ...parseLog(out), hasMore: false };
108
+ }
109
+ /** A branch, tag, remote branch, commit or HEAD~N from the browser, resolved to the commit it names (never an option, never a path). */
110
+ export async function resolveRef(root, ref) {
111
+ if (!ref || ref.length > 200 || !/^[\w][\w./@+~^-]*$/.test(ref) || ref.includes('..'))
112
+ throw new PathError('Invalid reference');
113
+ const r = await run(root, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], { okCodes: [1] });
114
+ const sha = r.out.trim();
115
+ if (!SHA.test(sha))
116
+ throw new PathError(`"${ref}" was not found`);
117
+ return sha;
118
+ }
75
119
  const isMergeCommit = async (root, sha) => (await run(root, ['rev-list', '--parents', '-n', '1', sha])).out.trim().split(' ').length > 2;
76
120
  const MAX_COMMIT_FILES = 500;
77
- export async function gitCommitDetail(cwd, sha) {
78
- if (!SHA.test(sha))
79
- throw new PathError('Invalid commit');
80
- const { root } = await repoOf(cwd);
81
- const isMerge = await isMergeCommit(root, sha);
82
- // A merge commit has no diff of its own; show what it brought in relative to the branch it was made on (first parent).
83
- const range = isMerge ? [`${sha}^1`, sha] : ['--root', sha];
84
- const [meta, names, nums] = await Promise.all([
85
- run(root, ['show', '-s', '--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%B', sha]),
86
- run(root, ['diff-tree', '-r', '-M', '--name-status', '-z', '--no-commit-id', ...range]),
87
- run(root, ['diff-tree', '-r', '-M', '--histogram', '--numstat', '-z', '--no-commit-id', ...range]),
88
- ]);
89
- const counts = parseNumstat(nums.out);
121
+ /** The files of a `diff-tree --name-status -z` plus its `--numstat -z`, as the explorer lists them (renames and counts included). */
122
+ export function filesFromDiffTree(namesOut, numsOut) {
123
+ const counts = parseNumstat(numsOut);
90
124
  const files = [];
91
- const st = names.out.split('\0');
125
+ const st = namesOut.split('\0');
92
126
  for (let i = 0; i < st.length; i++) {
93
127
  const code = st[i];
94
128
  if (!code)
@@ -105,6 +139,21 @@ export async function gitCommitDetail(cwd, sha) {
105
139
  files.push({ path, status: k === 'A' ? 'added' : k === 'D' ? 'deleted' : k === 'T' ? 'typechange' : 'modified', additions: c?.a ?? null, deletions: c?.d ?? null });
106
140
  }
107
141
  }
142
+ return files;
143
+ }
144
+ export async function gitCommitDetail(cwd, sha) {
145
+ if (!SHA.test(sha))
146
+ throw new PathError('Invalid commit');
147
+ const { root } = await repoOf(cwd);
148
+ const isMerge = await isMergeCommit(root, sha);
149
+ // A merge commit has no diff of its own; show what it brought in relative to the branch it was made on (first parent).
150
+ const range = isMerge ? [`${sha}^1`, sha] : ['--root', sha];
151
+ const [meta, names, nums] = await Promise.all([
152
+ run(root, ['show', '-s', '--format=%H%x1f%an%x1f%ae%x1f%aI%x1f%B', sha]),
153
+ run(root, ['diff-tree', '-r', '-M', '--name-status', '-z', '--no-commit-id', ...range]),
154
+ run(root, ['diff-tree', '-r', '-M', '--histogram', '--numstat', '-z', '--no-commit-id', ...range]),
155
+ ]);
156
+ const files = filesFromDiffTree(names.out, nums.out);
108
157
  const [h, author, email, date, ...msg] = meta.out.split('\x1f');
109
158
  return { sha: h, author, email, date, message: msg.join('\x1f').trim(), files: files.slice(0, MAX_COMMIT_FILES), truncated: files.length > MAX_COMMIT_FILES };
110
159
  }
@@ -174,6 +223,58 @@ export async function gitBranches(cwd) {
174
223
  }
175
224
  return res;
176
225
  }
226
+ const MAX_TAGS = 1000;
227
+ export async function gitTags(cwd) {
228
+ const { root } = await repoOf(cwd);
229
+ // %(*objectname) is the commit an annotated tag points to; a lightweight tag has none and %(objectname) is already the commit.
230
+ const { out } = await run(root, ['for-each-ref', '--sort=-creatordate', `--count=${MAX_TAGS + 1}`, '--format=%(refname:strip=2)%1f%(objecttype)%1f%(*objectname)%1f%(objectname)%1f%(creatordate:iso-strict)%1f%(contents:subject)', 'refs/tags']);
231
+ const all = out.split('\n').filter(Boolean).map((line) => {
232
+ const [name, type, peeled, direct, date, subject] = line.split('\x1f');
233
+ return { name, sha: peeled || direct, date, subject: subject ?? '', annotated: type === 'tag' };
234
+ });
235
+ return { tags: all.slice(0, MAX_TAGS), truncated: all.length > MAX_TAGS };
236
+ }
237
+ /** A tag name from the browser, resolved to the commit it points to. Only names that really are tags get through. */
238
+ async function tagCommit(root, tag) {
239
+ if (!tag || tag.length > 200 || tag.startsWith('-') || /[\s\0~^:?*[\\]/.test(tag))
240
+ throw new PathError('Invalid tag');
241
+ const r = await run(root, ['rev-parse', '--verify', '--quiet', `refs/tags/${tag}^{commit}`], { okCodes: [1] });
242
+ const sha = r.out.trim();
243
+ if (!SHA.test(sha))
244
+ throw new PathError(`Tag "${tag}" not found`);
245
+ return sha;
246
+ }
247
+ export async function gitTagTree(cwd, tag) {
248
+ const { root, scope } = await repoOf(cwd);
249
+ const sha = await tagCommit(root, tag);
250
+ const { out } = await run(root, ['ls-tree', '-r', '--name-only', '-z', sha, '--', scope || '.'], { raw: true });
251
+ const files = out.split('\0').filter(Boolean);
252
+ const MAX = 30_000;
253
+ return { tag, sha, files: files.slice(0, MAX), truncated: files.length > MAX };
254
+ }
255
+ const MAX_TAG_FILE = 1_000_000;
256
+ /** A file as it was at a commit (read-only, nothing is checked out). */
257
+ export async function fileAtCommit(root, sha, rel, source) {
258
+ if (!rel || rel.includes('\0') || rel.startsWith('/') || rel.split('/').includes('..'))
259
+ throw new PathError('Invalid path');
260
+ // The path must be a file of that commit (not a folder, not a submodule).
261
+ const kind = (await run(root, ['ls-tree', '-z', sha, '--', rel], { raw: true })).out.split('\0')[0] ?? '';
262
+ if (!/^100\d{3} blob /.test(kind) && !/^120000 blob /.test(kind))
263
+ throw new PathError('File not found at that version');
264
+ const size = Number((await run(root, ['cat-file', '-s', `${sha}:${rel}`])).out) || 0;
265
+ const { out } = await run(root, ['show', `${sha}:${rel}`], { raw: true });
266
+ const binary = out.slice(0, 8000).includes('\0');
267
+ return { path: rel, size, binary, truncated: !binary && out.length > MAX_TAG_FILE, content: binary ? '' : out.slice(0, MAX_TAG_FILE), source };
268
+ }
269
+ export async function gitTagFile(cwd, tag, rel) {
270
+ const { root } = await repoOf(cwd);
271
+ return fileAtCommit(root, await tagCommit(root, tag), rel, 'tag');
272
+ }
273
+ /** Any version of a file: a branch, a tag or a commit. */
274
+ export async function gitRefFile(cwd, ref, rel) {
275
+ const { root } = await repoOf(cwd);
276
+ return fileAtCommit(root, await resolveRef(root, ref), rel, 'ref');
277
+ }
177
278
  export const done = (output) => ({ ok: true, output });
178
279
  export async function gitCommitChanges(cwd, message, paths) {
179
280
  const msg = message.trim();
@@ -230,6 +331,22 @@ export async function gitSwitch(cwd, branch, create = false) {
230
331
  await validBranch(root, branch);
231
332
  return exclusive(root, async () => done((await run(root, create ? ['switch', '-c', branch] : ['switch', branch])).out || `On ${branch}`));
232
333
  }
334
+ /** Creates a branch at a tag, another branch or a commit WITHOUT switching to it: the working folder is not touched. */
335
+ export async function gitBranchCreate(cwd, branch, from) {
336
+ const { root } = await repoOf(cwd);
337
+ await validBranch(root, branch);
338
+ const sha = await resolveRef(root, from);
339
+ return exclusive(root, async () => done((await run(root, ['branch', '--', branch, sha])).out || `Created ${branch}`));
340
+ }
341
+ /** Deletes a local branch. Without `force` git refuses when it has commits that are not merged anywhere. */
342
+ export async function gitBranchDelete(cwd, branch, force = false) {
343
+ const { root } = await repoOf(cwd);
344
+ await validBranch(root, branch);
345
+ const cur = (await run(root, ['branch', '--show-current'])).out.trim();
346
+ if (cur === branch)
347
+ throw new GitOpError('You cannot delete the branch you are on. Switch to another one first.');
348
+ return exclusive(root, async () => done((await run(root, ['branch', force ? '-D' : '-d', '--', branch])).out || `Deleted ${branch}`));
349
+ }
233
350
  export async function gitMerge(cwd, branch) {
234
351
  const { root } = await repoOf(cwd);
235
352
  if (!branch || branch.startsWith('-') || branch.includes('\0'))
@@ -2,6 +2,7 @@ import { execFile } from 'node:child_process';
2
2
  import { promisify } from 'node:util';
3
3
  import { open, readdir, readFile, realpath, stat } from 'node:fs/promises';
4
4
  import { extname, isAbsolute, normalize, resolve, sep } from 'node:path';
5
+ import { DomainError } from '../errors.js';
5
6
  /**
6
7
  * Read-only git access for the explorer (writes live in gitops.ts). Everything here only *reads*: no command writes to the repository,
7
8
  * and `GIT_OPTIONAL_LOCKS=0` keeps `git status` from touching the index while an agent is working in it.
@@ -239,7 +240,7 @@ export async function gitList(cwd, rel) {
239
240
  .map((d) => ({ name: d.name, dir: d.isDirectory() })).sort((a, b) => (a.dir === b.dir ? a.name.localeCompare(b.name) : a.dir ? -1 : 1));
240
241
  return { path: rel.replace(/\/+$/, ''), entries: entries.slice(0, MAX_LISTED), truncated: entries.length > MAX_LISTED };
241
242
  }
242
- export class PathError extends Error {
243
+ export class PathError extends DomainError {
243
244
  }
244
245
  export const isPathError = (e) => e instanceof PathError;
245
246
  /** Repo-relative path → absolute path, guaranteed to be inside the agent's own folder (symlinks resolved). */
@@ -1,12 +1,15 @@
1
- import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { estimateCost } from '../pricing.js';
5
+ import { flattenText } from '../providers/spawn.js';
6
+ import { stripInstructions } from '../providers/preamble.js';
7
+ import { jsonLines } from './lines.js';
5
8
  const ROOT = join(homedir(), '.claude', 'projects');
6
9
  /** Claude encodes the project path by replacing both "/" and "." with "-". */
7
10
  const encode = (cwd) => cwd.replace(/[/.]/g, '-');
8
11
  /** An instructions update sent in front of a message (see providers/preamble.ts) is not part of what the user wrote. */
9
- const stripPreamble = (x) => x.replace(/^<instructions[^>]*>[\s\S]*?<\/instructions>\s*/, '');
12
+ const stripPreamble = stripInstructions;
10
13
  export function claudeFile(cwd, sessionId) {
11
14
  if (!/^[\w-]+$/.test(sessionId))
12
15
  return null;
@@ -23,24 +26,41 @@ export function claudeFile(cwd, sessionId) {
23
26
  catch { /* no Claude data yet */ }
24
27
  return null;
25
28
  }
29
+ /** Changes when the conversation changes (used to cache what is computed from it). */
30
+ export function claudeSig(cwd, sessionId) {
31
+ const f = claudeFile(cwd, sessionId);
32
+ if (!f)
33
+ return null;
34
+ try {
35
+ const st = statSync(f);
36
+ return `${st.mtimeMs}:${st.size}`;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ // Parsing a long transcript (tens of MB) takes about a second and the UI asks for it often: keep the last results while the file is unchanged.
43
+ const cache = new Map();
26
44
  export function readClaude(cwd, sessionId) {
27
45
  const file = claudeFile(cwd, sessionId);
28
46
  if (!file)
29
47
  return [];
48
+ const st = statSync(file), sig = `${st.mtimeMs}:${st.size}`, hit = cache.get(file);
49
+ if (hit && hit.sig === sig)
50
+ return hit.msgs;
51
+ const msgs = parseClaude(file);
52
+ cache.delete(file);
53
+ cache.set(file, { sig, msgs });
54
+ if (cache.size > 8)
55
+ cache.delete(cache.keys().next().value);
56
+ return msgs;
57
+ }
58
+ function parseClaude(file) {
30
59
  const out = [];
31
60
  const byMsgId = new Map();
32
61
  const toolBlocks = new Map();
33
62
  const toolStart = new Map();
34
- for (const line of readFileSync(file, 'utf8').split('\n')) {
35
- if (!line)
36
- continue;
37
- let e;
38
- try {
39
- e = JSON.parse(line);
40
- }
41
- catch {
42
- continue;
43
- }
63
+ for (const e of jsonLines(file)) {
44
64
  if (e.isSidechain || e.isMeta || (e.type !== 'user' && e.type !== 'assistant'))
45
65
  continue;
46
66
  const m = e.message;
@@ -60,7 +80,7 @@ export function readClaude(cwd, sessionId) {
60
80
  if (b.type === 'tool_result') {
61
81
  const t = toolBlocks.get(b.tool_use_id);
62
82
  if (t) {
63
- t.output = flat(b.content);
83
+ t.output = flattenText(b.content);
64
84
  t.error = !!b.is_error;
65
85
  const st = toolStart.get(b.tool_use_id);
66
86
  if (st && ts)
@@ -119,10 +139,3 @@ export function claudeMeta(file) {
119
139
  const st = statSync(file);
120
140
  return { mtime: st.mtimeMs, size: st.size };
121
141
  }
122
- function flat(c) {
123
- if (typeof c === 'string')
124
- return c;
125
- if (Array.isArray(c))
126
- return c.map((x) => (typeof x === 'string' ? x : x?.text ?? '')).join('\n');
127
- return c == null ? '' : JSON.stringify(c);
128
- }
@@ -1,19 +1,27 @@
1
- import { readClaude } from './claude.js';
2
- import { readOpencode } from './opencode.js';
3
- import { readKiro } from './kiro.js';
4
- /** The conversation lives in each CLI's own store; hive-am only keeps the pointer. */
1
+ import { claudeSig, readClaude } from './claude.js';
2
+ import { opencodeSig, readOpencode } from './opencode.js';
3
+ import { kiroSig, readKiro } from './kiro.js';
4
+ export const historyReaders = {
5
+ claude: { read: readClaude, sig: claudeSig },
6
+ opencode: { read: (_cwd, id) => readOpencode(id), sig: (_cwd, id) => opencodeSig(id) },
7
+ kiro: { read: (_cwd, id) => readKiro(id), sig: (_cwd, id) => kiroSig(id) },
8
+ };
5
9
  export function readHistory(a, sessionId = a.session_id) {
6
10
  if (!sessionId)
7
11
  return [];
8
12
  try {
9
- switch (a.provider) {
10
- case 'claude': return readClaude(a.cwd, sessionId);
11
- case 'opencode': return readOpencode(sessionId);
12
- case 'kiro': return readKiro(sessionId);
13
- }
13
+ return historyReaders[a.provider].read(a.cwd, sessionId);
14
14
  }
15
15
  catch (e) {
16
16
  console.error('[history]', a.provider, sessionId, e);
17
17
  return [];
18
18
  }
19
19
  }
20
+ export function historySig(a, sessionId) {
21
+ try {
22
+ return historyReaders[a.provider].sig(a.cwd, sessionId);
23
+ }
24
+ catch {
25
+ return null;
26
+ }
27
+ }
@@ -1,8 +1,22 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
3
  import { join } from 'node:path';
4
+ import { stripInstructions } from '../providers/preamble.js';
5
+ import { jsonLines } from './lines.js';
4
6
  const DIR = join(homedir(), '.kiro', 'sessions', 'cli');
5
7
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ /** Changes when the conversation changes (used to cache what is computed from it). */
9
+ export function kiroSig(sessionId) {
10
+ if (!UUID.test(sessionId))
11
+ return null;
12
+ try {
13
+ const st = statSync(join(DIR, `${sessionId}.jsonl`));
14
+ return `${st.mtimeMs}:${st.size}`;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
6
20
  export function readKiro(sessionId) {
7
21
  if (!UUID.test(sessionId))
8
22
  return [];
@@ -16,16 +30,7 @@ export function readKiro(sessionId) {
16
30
  const flush = () => { if (lastAssistant && turnIdx >= 0 && turns[turnIdx])
17
31
  lastAssistant.meta = turns[turnIdx]; lastAssistant = null; };
18
32
  const tools = new Map();
19
- for (const line of readFileSync(file, 'utf8').split('\n')) {
20
- if (!line)
21
- continue;
22
- let e;
23
- try {
24
- e = JSON.parse(line);
25
- }
26
- catch {
27
- continue;
28
- }
33
+ for (const e of jsonLines(file)) {
29
34
  const d = e.data;
30
35
  if (!d)
31
36
  continue;
@@ -35,7 +40,7 @@ export function readKiro(sessionId) {
35
40
  turnIdx++;
36
41
  const text = (d.content ?? []).filter((b) => b.kind === 'text').map((b) => b.data).join('');
37
42
  if (text.trim())
38
- out.push({ id: d.message_id, role: 'user', ts, blocks: [{ type: 'text', text: text.replace(/^<instructions[^>]*>[\s\S]*?<\/instructions>\s*/, '') }] });
43
+ out.push({ id: d.message_id, role: 'user', ts, blocks: [{ type: 'text', text: stripInstructions(text) }] });
39
44
  }
40
45
  else if (e.kind === 'AssistantMessage') {
41
46
  const blocks = [];
@@ -0,0 +1,12 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /** The JSON objects of a JSONL transcript, in order. A line that is cut or damaged (a CLI still writing) is skipped, not fatal. */
3
+ export function* jsonLines(file) {
4
+ for (const line of readFileSync(file, 'utf8').split('\n')) {
5
+ if (!line)
6
+ continue;
7
+ try {
8
+ yield JSON.parse(line);
9
+ }
10
+ catch { /* partial line */ }
11
+ }
12
+ }
@@ -3,7 +3,24 @@ import { existsSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
4
  import { join } from 'node:path';
5
5
  import { estimateCost } from '../pricing.js';
6
+ import { stripInstructions as stripPreamble } from '../providers/preamble.js';
6
7
  const DB = join(homedir(), '.local', 'share', 'opencode', 'opencode.db');
8
+ /** Changes when the conversation changes (used to cache what is computed from it). */
9
+ export function opencodeSig(sessionId) {
10
+ if (!existsSync(DB))
11
+ return null;
12
+ const db = new Database(DB, { readonly: true, fileMustExist: true });
13
+ try {
14
+ const r = db.prepare('SELECT COUNT(*) n, COALESCE(MAX(time_created), 0) t FROM session_message WHERE session_id=?').get(sessionId);
15
+ return `${r.n}:${r.t}`;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ finally {
21
+ db.close();
22
+ }
23
+ }
7
24
  export function readOpencode(sessionId) {
8
25
  if (!existsSync(DB))
9
26
  return [];
@@ -58,7 +75,7 @@ function stripInstructions(t) {
58
75
  let x = t.trim();
59
76
  if (x.length > 1 && x.startsWith('"') && x.endsWith('"'))
60
77
  x = x.slice(1, -1);
61
- return x.replace(/^<instructions[^>]*>[\s\S]*?<\/instructions>\s*/, '').trim();
78
+ return stripPreamble(x).trim();
62
79
  }
63
80
  /** Folder OpenCode recorded for a session, or null if unknown. Used to avoid resuming a session that belongs to another folder. */
64
81
  export function opencodeSessionDir(sessionId) {
@@ -0,0 +1,46 @@
1
+ import { UPLOAD_MAX_BYTES, UPLOAD_MAX_FILES } from '../connections/files.js';
2
+ import { bad, tooLarge } from './errors.js';
3
+ /** Notes, skills, SQL, http files and settings are text: 2 MiB is far above what any of them needs. */
4
+ export const JSON_LIMIT = 2 * 1024 * 1024;
5
+ /** Files sent from the chat travel as base64 inside the JSON (4/3 of the bytes), up to UPLOAD_MAX_FILES of UPLOAD_MAX_BYTES each. */
6
+ export const UPLOAD_LIMIT = Math.ceil(UPLOAD_MAX_FILES * UPLOAD_MAX_BYTES * 4 / 3) + JSON_LIMIT;
7
+ const mb = (n) => `${Math.round(n / 1048576)} MB`;
8
+ /** Raw bytes of the request, refusing (413) anything over `max`. Once over the limit the rest is read and dropped, so the answer can still be delivered. */
9
+ export function readRaw(req, max) {
10
+ return new Promise((resolve, reject) => {
11
+ const declared = Number(req.headers['content-length']);
12
+ const chunks = [];
13
+ let size = 0, over = false;
14
+ const refuse = () => { if (over)
15
+ return; over = true; chunks.length = 0; reject(tooLarge(`The request is too big (limit ${mb(max)})`)); };
16
+ if (Number.isFinite(declared) && declared > max) {
17
+ refuse();
18
+ req.resume();
19
+ return;
20
+ }
21
+ req.on('data', (c) => { if (over)
22
+ return; size += c.length; if (size > max)
23
+ return refuse(); chunks.push(c); });
24
+ req.on('end', () => { if (!over)
25
+ resolve(Buffer.concat(chunks)); });
26
+ req.on('error', (e) => { if (!over) {
27
+ over = true;
28
+ reject(e);
29
+ } });
30
+ });
31
+ }
32
+ export async function body(req, max = JSON_LIMIT) {
33
+ const raw = await readRaw(req, max);
34
+ if (!raw.length)
35
+ return {};
36
+ let v;
37
+ try {
38
+ v = JSON.parse(raw.toString('utf8'));
39
+ }
40
+ catch {
41
+ throw bad('Request body is not valid JSON');
42
+ }
43
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
44
+ throw bad('Request body must be a JSON object');
45
+ return v;
46
+ }
@@ -0,0 +1,21 @@
1
+ import { DomainError, STATUS_OF_KIND } from '../errors.js';
2
+ /** An error with the status the client should see. Its message is shown to the person, so it is in plain English. */
3
+ export class HttpError extends Error {
4
+ status;
5
+ constructor(status, msg) {
6
+ super(msg);
7
+ this.status = status;
8
+ }
9
+ }
10
+ export const bad = (m) => new HttpError(400, m);
11
+ export const forbidden = (m) => new HttpError(403, m);
12
+ export const notFound = (m = 'Not found') => new HttpError(404, m);
13
+ export const tooLarge = (m) => new HttpError(413, m);
14
+ /** The one place where an error becomes a status and a message. `null` means "not ours": the caller logs it and answers 500. */
15
+ export function toHttpError(e) {
16
+ if (e instanceof HttpError)
17
+ return e;
18
+ if (e instanceof DomainError)
19
+ return new HttpError(STATUS_OF_KIND[e.kind] ?? 400, e.message);
20
+ return null;
21
+ }